diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000000..0425515608 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,55 @@ +--- +name: Bug Report +about: If things aren't working as expected. +title: '' +labels: '' +assignees: '' + +--- + +## Bug Report + + + +#### What did you do? + + + +#### What did you expect to see? + + + +#### What did you see instead? Under which circumstances? + + + +#### Environment + +**Kubernetes cluster type:** + + + +`$ Mention java-operator-sdk version from pom.xml file` + + + +`$ java -version` + + + +`$ kubectl version` + + + +#### Possible Solution + + + +#### Additional context + + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/enhancement-request.md b/.github/ISSUE_TEMPLATE/enhancement-request.md new file mode 100644 index 0000000000..11538f9d59 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement-request.md @@ -0,0 +1,20 @@ +--- +name: Enhancement request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..60fc50a926 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: "daily" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/main.workflow b/.github/main.workflow deleted file mode 100644 index 2748505a6e..0000000000 --- a/.github/main.workflow +++ /dev/null @@ -1,10 +0,0 @@ -workflow "Maven build" { - resolves = ["GitHub Action for Maven"] - on = "push" -} - -action "GitHub Action for Maven" { - uses = "LucaFeger/action-maven-cli@765e218a50f02a12a7596dc9e7321fc385888a27" - runs = "mvn" - args = "package" -} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..25b234846a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,48 @@ +name: Build with Kubernetes + +env: + MAVEN_ARGS: -V -ntp -e + +on: + workflow_call: + +jobs: + integration_tests: + strategy: + matrix: + java: [ 17, 21, 25 ] + # 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: [ '1.30.12', '1.31.8', '1.32.4','1.33.1' ] + uses: ./.github/workflows/integration-tests.yml + with: + java-version: ${{ matrix.java }} + kube-version: ${{ matrix.kubernetes }} + + httpclient-tests: + strategy: + matrix: + httpclient: [ 'vertx', 'jdk', 'jetty' ] + uses: ./.github/workflows/integration-tests.yml + with: + java-version: 25 + kube-version: '1.32.0' + http-client: ${{ matrix.httpclient }} + experimental: true + + special_integration_tests: + name: "Special integration tests (${{ matrix.java }})" + runs-on: ubuntu-latest + strategy: + matrix: + java: [ 17, 21, 25 ] + steps: + - uses: actions/checkout@v5 + - name: Set up Java and Maven + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ matrix.java }} + - name: Run Special Integration Tests + run: ./mvnw ${MAVEN_ARGS} -B package -P minimal-watch-timeout-dependent-it --file pom.xml diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 0000000000..7aa92a409c --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,60 @@ +# Integration and end to end tests which runs locally and deploys the Operator to a Kubernetes +# (Minikube) cluster and creates custom resources to verify the operator's functionality +name: End to End tests +on: + pull_request: + paths-ignore: + - 'docs/**' + - 'adr/**' + branches: [ main, next ] + push: + paths-ignore: + - 'docs/**' + - 'adr/**' + branches: + - main + - next + +jobs: + sample_operators_tests: + strategy: + matrix: + sample: + - "sample-operators/mysql-schema" + - "sample-operators/tomcat-operator" + - "sample-operators/webpage" + - "sample-operators/leader-election" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Minikube-Kubernetes + uses: manusa/actions-setup-minikube@v2.14.0 + with: + minikube version: v1.36.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.33.1 + github token: ${{ secrets.GITHUB_TOKEN }} + driver: docker + + - name: Set up Java and Maven + uses: actions/setup-java@v5 + with: + java-version: 25 + distribution: temurin + cache: 'maven' + + - name: Build SDK + run: mvn install -DskipTests + + - name: Run integration tests in local mode + run: | + mvn test -P end-to-end-tests -pl ${{ matrix.sample }} + + - name: Run E2E tests as a deployment + run: | + eval $(minikube -p minikube docker-env) + mvn jib:dockerBuild test -P end-to-end-tests -Dtest.deployment=remote -pl ${{ matrix.sample }} diff --git a/.github/workflows/hugo.yaml b/.github/workflows/hugo.yaml new file mode 100644 index 0000000000..2c0a63d50d --- /dev/null +++ b/.github/workflows/hugo.yaml @@ -0,0 +1,85 @@ +# Sample workflow for building and deploying a Hugo site to GitHub Pages +name: Deploy Hugo site to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: + - main + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +# Default to bash +defaults: + run: + shell: bash + +jobs: + # Build job + build: + runs-on: ubuntu-latest + env: + HUGO_VERSION: 0.145.0 + steps: + - name: Install Hugo CLI + run: | + wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \ + && sudo dpkg -i ${{ runner.temp }}/hugo.deb + - name: Install Dart Sass + run: sudo snap install dart-sass + - name: Checkout + uses: actions/checkout@v5 + with: + submodules: recursive + fetch-depth: 0 + - name: Setup Pages + id: pages + uses: actions/configure-pages@v5 + - name: Install Node.js dependencies + working-directory: ./docs + run: | + [[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true + npm install -D autoprefixer + npm install -D postcss-cli + npm install -D postcss + - name: Build with Hugo + env: + # For maximum backward compatibility with Hugo modules + HUGO_ENVIRONMENT: production + HUGO_ENV: production + TZ: America/Los_Angeles + working-directory: ./docs + run: | + hugo \ + --gc \ + --minify \ + --baseURL "${{ steps.pages.outputs.base_url }}/" + - name: Upload artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./docs/public + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000000..fdb8897c07 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,57 @@ +name: Parameterized Integration Tests + +on: + workflow_call: + inputs: + java-version: + type: string + required: true + kube-version: + type: string + required: true + http-client: + type: string + required: false + default: 'vertx' + experimental: + type: boolean + required: false + default: false + checkout-ref: + type: string + required: false + default: '' + +jobs: + integration_tests: + name: Integration tests (${{ inputs.java-version }}, ${{ inputs.kube-version }}, ${{ inputs.http-client }}) + runs-on: ubuntu-latest + continue-on-error: ${{ inputs.experimental }} + timeout-minutes: 40 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.checkout-ref }} + - name: Set up Java and Maven + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ inputs.java-version }} + cache: 'maven' + - name: Set up Minikube + uses: manusa/actions-setup-minikube@v2.14.0 + with: + minikube version: 'v1.36.0' + kubernetes version: '${{ inputs.kube-version }}' + github token: ${{ github.token }} + + - name: "${{inputs.it-category}} integration tests (kube: ${{ inputs.kube-version }} / java: ${{ inputs.java-version }} / client: ${{ inputs.http-client }})" + run: | + if [ -z "${{inputs.it-category}}" ]; then + it_profile="integration-tests" + else + it_profile="integration-tests-${{inputs.it-category}}" + fi + echo "Using profile: ${it_profile}" + ./mvnw ${MAVEN_ARGS} -T1C -B install -DskipTests -Pno-apt --file pom.xml + ./mvnw ${MAVEN_ARGS} -T1C -B package -P${it_profile} -Dfabric8-httpclient-impl.name=${{inputs.http-client}} --file pom.xml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000000..79660cfb1b --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,34 @@ +name: Verify Pull Request + +env: + MAVEN_ARGS: -V -ntp -e + +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true +on: + pull_request: + paths-ignore: + - 'docs/**' + - 'adr/**' + branches: [ main, v1, v2, v3, next ] + workflow_dispatch: +jobs: + check_format_and_unit_tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up Java and Maven + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 25 + cache: 'maven' + - name: Check code format + run: | + ./mvnw ${MAVEN_ARGS} spotless:check --file pom.xml + - name: Run unit tests + run: ./mvnw ${MAVEN_ARGS} clean install -Pno-apt --file pom.xml + + build: + uses: ./.github/workflows/build.yml diff --git a/.github/workflows/release-project-in-dir.yml b/.github/workflows/release-project-in-dir.yml new file mode 100644 index 0000000000..0313aebe4d --- /dev/null +++ b/.github/workflows/release-project-in-dir.yml @@ -0,0 +1,83 @@ +name: Release project in specified directory + +on: + workflow_call: + inputs: + project_dir: + type: string + required: true + version_branch: + type: string + required: true + +env: +# set the target pom to use the input directory as root + MAVEN_ARGS: -V -ntp -e -f ${{ inputs.project_dir }}/pom.xml + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout "${{inputs.version_branch}}" branch + uses: actions/checkout@v5 + with: + ref: "${{inputs.version_branch}}" + + - name: Set up Java and Maven + uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: temurin + cache: 'maven' + server-id: central + server-username: MAVEN_USERNAME + server-password: MAVEN_CENTRAL_TOKEN + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + + - name: Change version to release version + # Assume that RELEASE_VERSION will have form like: "v1.0.1". So we cut the "v" + run: | + mvn ${MAVEN_ARGS} versions:set -DnewVersion="${RELEASE_VERSION:1}" versions:commit -DprocessAllModules + env: + RELEASE_VERSION: ${{ github.event.release.tag_name }} + + - name: Publish to Apache Maven Central + run: mvn package deploy -Prelease + env: + MAVEN_USERNAME: ${{ secrets.NEXUS_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.NEXUS_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + + # This is separate job because there were issues with git after release step, was not able to commit changes. + update-working-version: + runs-on: ubuntu-latest + needs: publish + if: "!contains(github.event.release.tag_name, 'RC')" # not sure we should keep this the RC part + steps: + - name: Checkout "${{inputs.version_branch}}" branch + uses: actions/checkout@v5 + with: + ref: "${{inputs.version_branch}}" + + - name: Set up Java and Maven + uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: temurin + cache: 'maven' + + - name: Update version to new SNAPSHOT version + run: | + mvn ${MAVEN_ARGS} build-helper:parse-version versions:set -DnewVersion=\${parsedVersion.majorVersion}.\${parsedVersion.minorVersion}.\${parsedVersion.nextIncrementalVersion}-SNAPSHOT versions:commit -DprocessAllModules + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git commit -m "Set new SNAPSHOT version into pom files." -a + env: + RELEASE_VERSION: ${{ github.event.release.tag_name }} + + - name: Push changes to branch + uses: ad-m/github-push-action@master + with: + branch: "${{inputs.version_branch}}" + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..e7826ce613 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release to Maven Central +env: + MAVEN_ARGS: -V -ntp -e +on: + release: + types: [ released ] +jobs: + + prepare-release: + runs-on: ubuntu-latest + env: + tmp_version_branch: '' + outputs: + version_branch: ${{ steps.set-version-branch.outputs.version_branch }} + steps: + - if: ${{ startsWith(github.event.release.tag_name, 'v1.' ) }} + run: | + echo "Setting version_branch to v1" + echo "tmp_version_branch=v1" >> "$GITHUB_ENV" + - if: ${{ startsWith(github.event.release.tag_name, 'v2.' ) }} + run: | + echo "Setting version_branch to v2" + echo "tmp_version_branch=v2" >> "$GITHUB_ENV" + - if: ${{ startsWith(github.event.release.tag_name, 'v3.' ) }} + run: | + echo "Setting version_branch to v3" + echo "tmp_version_branch=v3" >> "$GITHUB_ENV" + - if: ${{ startsWith(github.event.release.tag_name, 'v4.' ) }} + run: | + echo "Setting version_branch to v4" + echo "tmp_version_branch=v4" >> "$GITHUB_ENV" + - if: ${{ startsWith(github.event.release.tag_name, 'v5.' ) }} + run: | + echo "Setting version_branch to main" + echo "tmp_version_branch=main" >> "$GITHUB_ENV" + - if: ${{ env.tmp_version_branch == '' }} + name: Fail if version_branch is not set + run: | + echo "Failed to find appropriate branch to release ${{github.event.release.tag_name}} from" + exit 1 + - id: set-version-branch + name: Set version_branch if matched + run: echo "version_branch=${{env.tmp_version_branch}}" >> $GITHUB_OUTPUT + + release-sdk: + needs: prepare-release + uses: ./.github/workflows/release-project-in-dir.yml + secrets: inherit + with: + version_branch: ${{needs.prepare-release.outputs.version_branch}} + project_dir: '.' diff --git a/.github/workflows/snapshot-releases.yml b/.github/workflows/snapshot-releases.yml new file mode 100644 index 0000000000..0f560dd2cb --- /dev/null +++ b/.github/workflows/snapshot-releases.yml @@ -0,0 +1,50 @@ +name: Test & Release Snapshot to Maven Central + +env: + MAVEN_ARGS: -V -ntp -e + +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true +on: + push: + paths-ignore: + - 'docs/**' + branches: [ main, v1, v2, v3, next ] + workflow_dispatch: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up Java and Maven + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + cache: 'maven' + - name: Build and test project + run: ./mvnw ${MAVEN_ARGS} clean install --file pom.xml + release-snapshot: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v5 + - name: Set up Java and Maven + uses: actions/setup-java@v5 + with: + java-version: 21 + distribution: temurin + cache: 'maven' + server-id: central + server-username: MAVEN_USERNAME + server-password: MAVEN_CENTRAL_TOKEN + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + + - name: Publish to Apache Maven Central + run: mvn package deploy -Prelease + env: + MAVEN_USERNAME: ${{ secrets.NEXUS_USERNAME }} + MAVEN_CENTRAL_TOKEN: ${{ secrets.NEXUS_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 0000000000..132575edaa --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,44 @@ +name: Sonar + +env: + MAVEN_ARGS: -V -ntp -e + +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true +on: + push: + paths-ignore: + - 'docs/**' + - 'adr/**' + branches: [ main ] + pull_request: + paths-ignore: + - 'docs/**' + - 'adr/**' + types: [ opened, synchronize, reopened ] + +jobs: + test: + runs-on: ubuntu-latest + if: ${{ ( github.event_name == 'push' ) || ( github.event_name == 'pull_request' && github.event.pull_request.head.repo.owner.login == 'java-operator-sdk' ) }} + steps: + - uses: actions/checkout@v5 + - name: Set up Java and Maven + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 25 + cache: 'maven' + - name: Cache SonarCloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: mvn -B org.jacoco:jacoco-maven-plugin:prepare-agent clean install verify org.jacoco:jacoco-maven-plugin:report org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=java-operator-sdk_java-operator-sdk + diff --git a/.gitignore b/.gitignore index db846856f4..638e4a93f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,17 @@ target/ *.iml -.idea/ \ No newline at end of file +.idea/ + +# Eclipse +.settings/ +.classpath +.project +.cache/ + +# VSCode +.factorypath + +.mvn/wrapper/maven-wrapper.jar + +.java-version +.aider* diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 0000000000..b901097f2d --- /dev/null +++ b/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,117 @@ +/* + * Copyright 2007-present the original author or 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. + */ +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + private static final String WRAPPER_VERSION = "0.5.6"; + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" + + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to + * use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = + ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = + ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if(mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if(mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if(!outputFile.getParentFile().exists()) { + if(!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { + String username = System.getenv("MVNW_USERNAME"); + char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password); + } + }); + } + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..8c79a83ae4 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.4/apache-maven-3.8.4-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c500b6ec91..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: java -dist: bionic -cache: - directories: - - $HOME/.m2 -before_install: -- echo $GPGKEY | base64 --decode | gpg --import -script: -- mvn deploy --settings=maven-settings.xml -Prelease diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..797938cf3c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "java.format.settings.url": "https://raw.githubusercontent.com/google/styleguide/gh-pages/eclipse-java-google-style.xml", + "java.completion.importOrder": [ + "java", + "javax", + "org", + "io", + "com", + "", + "#", + ] +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..f3cd19aa9d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,134 @@ +# Code of Conduct + +All participants to the Java Operator SDK project are required to comply with +the following code of conduct, which is based on v2.0 of the [Contributor +Covenant](https://www.contributor-covenant.org/). + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to adam.sandor@container-solutions.com or any of the project admins. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..c3a9e63545 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing To Java Operator SDK + +Firstly, big thanks for considering contributing to the project. We really hope to make this into a +community project and to do that we need your help! + +## Code of Conduct + +We are serious about making this a welcoming, happy project. We will not tolerate discrimination, +aggressive or insulting behaviour. + +To this end, the project and everyone participating in it is bound by the [Code of +Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report +unacceptable behaviour to any of the project admins or adam.sandor@container-solutions.com. + +## Bugs + +If you find a bug, please [open an issue](https://github.com/operator-framework/java-operator-sdk/issues)! Do try +to include all the details needed to recreate your problem. This is likely to include: + + - The version of the Operator SDK being used + - The exact platform and version of the platform that you're running on + - The steps taken to cause the bug + +## Building Features and Documentation + +If you're looking for something to work on, take look at the issue tracker, in particular any items +labelled [good first issue](https://github.com/operator-framework/java-operator-sdk/labels/good%20first%20issue). +Please leave a comment on the issue to mention that you have started work, in order to avoid +multiple people working on the same issue. + +If you have an idea for a feature - whether or not you have time to work on it - please also open an +issue describing your feature and label it "enhancement". We can then discuss it as a community and +see what can be done. Please be aware that some features may not align with the project goals and +might therefore be closed. In particular, please don't start work on a new feature without +discussing it first to avoid wasting effort. We do commit to listening to all proposals and will do +our best to work something out! + +Once you've got the go ahead to work on a feature, you can start work. Feel free to communicate with +team via updates on the issue tracker or the [Discord channel](https://discord.gg/DacEhAy) and ask for feedback, pointers etc. +Once you're happy with your code, go ahead and open a Pull Request. + +## Pull Request Process + +First, please format your commit messages so that they follow the [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) format. + +On opening a PR, a GitHub action will execute the test suite against the new code. All code is +required to pass the tests, and new code must be accompanied by new tests. + +All PRs have to be reviewed and signed off by another developer before being merged to the master +branch. This review will likely ask for some changes to the code - please don't be alarmed or upset +at this; it is expected that all PRs will need tweaks and a normal part of the process. + +The PRs are checked to be compliant with the Java Google code style. + +Be aware that all Operator SDK code is released under the [Apache 2.0 licence](LICENSE). + +## Development environment setup + +### Code style + +The SDK modules and samples are formatted to follow the Java Google code style. +On every `compile` the code gets formatted automatically, +however, to make things simpler (i.e. avoid getting a PR rejected simply because of code style issues), you can import one of the following code style schemes based on the IDE you use: + +- for *Intellij IDEA* + install [google-java-format](https://plugins.jetbrains.com/plugin/8527-google-java-format) plugin +- for *Eclipse* + follow [these intructions](https://github.com/google/google-java-format?tab=readme-ov-file#eclipse) + +## Thanks + +These guidelines were best on several sources, including +[Atom](https://github.com/atom/atom/blob/master/CONTRIBUTING.md), [PurpleBooth's +advice](https://gist.github.com/PurpleBooth/b24679402957c63ec426) and the [Contributor +Covenant](https://www.contributor-covenant.org/). diff --git a/OWNERS b/OWNERS new file mode 100644 index 0000000000..3a63d7d7d0 --- /dev/null +++ b/OWNERS @@ -0,0 +1,13 @@ +approvers: +- csviri +- metacosm +- andreaTP +- xstefank +reviewers: +- gyfora +- mbalassi +- scrocquesel +- csviri +- metacosm +- xstefank + diff --git a/README.md b/README.md index 0442d211fb..5bb2758ae5 100644 --- a/README.md +++ b/README.md @@ -1,171 +1,85 @@ -# java-operator-sdk -[![Build Status](https://travis-ci.org/ContainerSolutions/java-operator-sdk.svg?branch=master)](https://travis-ci.org/ContainerSolutions/java-operator-sdk) +# ![java-operator-sdk](docs/static/images/full_logo.png) -SDK for building Kubernetes Operators in Java. Inspired by [operator-sdk](https://github.com/operator-framework/operator-sdk). -In this first iteration we aim to provide a framework which handles the reconciliation loop by dispatching events to -a Controller written by the user of the framework. +![Java CI with Maven](https://github.com/operator-framework/java-operator-sdk/actions/workflows/snapshot-releases.yml/badge.svg) +[![Slack](https://img.shields.io/badge/Slack-4A154B?style=flat-square&logo=slack&logoColor=white)](https://kubernetes.slack.com/archives/CAW0GV7A5 "get invite here: https://communityinviter.com/apps/kubernetes/community" ) +[![Discord](https://img.shields.io/discord/723455000604573736.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.com/channels/723455000604573736) -The Controller only contains the logic to create, update and delete the actual resources related to the CRD. +# Build Kubernetes Operators in Java Without Hassle -## Roadmap +Java Operator SDK is a production-ready framework that makes implementing Kubernetes Operators in Java easy. -Feature we would like to implement and invite the community to help us implement in the future: +It provides a controller runtime, support for testing operators, and related tooling. In addition to that implementing +conversion hooks and dynamic admission controllers are supported as a separate project +(and much more, see related projects section). -* ~~Spring Boot support~~ -* Testing support -* Class generation from CRD to POJO +Under the hood it uses the excellent [Fabric8 Kubernetes Client](https://github.com/fabric8io/kubernetes-client), +which provides additional facilities, like generating CRD from source code (and vice versa). -## Usage +Icon -We have several sample Operators under the samples directory: -* *basic*: Minimal Operator implementation which only parses the Custom Resource and prints to stdout. -Implemented with and without Spring Boot support. The two samples share the common module. -* *webserver*: More realistic example creating an nginx webserver from a Custom Resource containing html code. +Java Operator SDK is a CNCF project as part of [Operator Framework](https://github.com/operator-framework). -Add dependency to your project: +## Documentation -```xml - - com.github.containersolutions - operator-framework - {see https://search.maven.org/search?q=a:operator-framework for latest version} - -``` +Documentation can be found on the **[JOSDK WebSite](https://javaoperatorsdk.io/)**. -Main method initializing the Operator and registering a controller.. +## Contact us -```java -public class Runner { +Join us on [Discord](https://discord.gg/DacEhAy) or feel free to ask any question on +[Kubernetes Slack Operator Channel](https://kubernetes.slack.com/archives/CAW0GV7A5) - public static void main(String[] args) { - Operator operator = new Operator(new DefaultKubernetesClient()); - operator.registerController(new CustomServiceController()); - } -} -``` +**Meet us** every other Tuesday 15:00 CEST (from 29.10.2024) at our **community meeting** on [Zoom](https://zoom.us/j/8415370125) +(Password in the Discord channel, or just ask for it there!) -The Controller implements the business logic and describes all the classes needed to handle the CRD. +## How to Contribute -```java -@Controller(customResourceClass = WebServer.class, - crdName = "webservers.sample.javaoperatorsdk", - customResourceListClass = WebServerList.class, - customResourceDonebaleClass = WebServerDoneable.class) -public class WebServerController implements ResourceController { +See the [contribution](https://javaoperatorsdk.io/docs/contributing) guide on the website. - @Override - public boolean deleteResource(CustomService resource) { - // ... your logic ... - return true; - } - - // Return the changed resource, so it gets updated. See javadoc for details. - @Override - public Optional createOrUpdateResource(CustomService resource) { - // ... your logic ... - return resource; - } -} -``` +## What is Java Operator SDK -Our custom resource java representation +Java Operator SDK is a higher level framework and related tooling to support writing Kubernetes Operators in Java. +It makes it easy to implement best practices and patterns for an Operator. Features include: -```java -public class WebServer extends CustomResource { +* Optimal handling Kubernetes API events +* Handling dependent resources, related events, and caching. +* Automatic Retries +* Smart event scheduling +* Easy to use Error Handling +* ... and everything that a batteries included framework needs - private WebServerSpec spec; +For all features and their usage see the [related sections on the website](https://javaoperatorsdk.io/docs/documentation/). - private WebServerStatus status; +## Related Projects - public WebServerSpec getSpec() { - return spec; - } +* Quarkus Extension: https://github.com/quarkiverse/quarkus-operator-sdk +* Spring Boot Starter: https://github.com/java-operator-sdk/operator-framework-spring-boot-starter +* Kubernetes Glue Operator: https://github.com/java-operator-sdk/kubernetes-glue-operator + Meta-operator that builds upon to use JOSDK Workflows and Dependent Resources features and + allows to create operators by simply applying a custom resource, thus, is a language independent way. +* Kubernetes Webhooks Framework: https://github.com/java-operator-sdk/kubernetes-webooks-framework + Framework to implement Admission Controllers and Conversion Hooks. +* Operator SDK plugin: https://github.com/operator-framework/java-operator-plugins - public void setSpec(WebServerSpec spec) { - this.spec = spec; - } +## Projects using JOSDK - public WebServerStatus getStatus() { - return status; - } +While we know of multiple projects using JOSDK in production, we don't want to presume these +projects want to advertise that fact here. For this reason, we ask that if you'd like your project +to be featured in this section, please open a PR, adding a link to and short description of your +project, as shown below: - public void setStatus(WebServerStatus status) { - this.status = status; - } -} - -public class WebServerSpec { - - private String html; - - public String getHtml() { - return html; - } - - public void setHtml(String html) { - this.html = html; - } -} -``` - -## Spring Boot Support - -We provide a spring boot starter to automatically handle bean registration, and registering various components as beans. -To use it just include the following dependency to you project: - -``` - - com.github.containersolutions - spring-boot-operator-framework-starter - [version] - -``` - -Note that controllers needs to be registered as beans in the Spring context. For example adding the `@Component` annotation -on the classes will work. -See Spring docs for for details, also our spring-boot with component scanning. -All controllers that are registered as a bean, gets automatically registered to operator. - -Kubernetes client creation using properties is also supported, for complete list see: [Link for config class] - - -## Implementation / Design details - -This library relies on the amazing [kubernetes-client](https://github.com/fabric8io/kubernetes-client) from fabric8. -Most of the heavy lifting is actually done by kubernetes-client. - -What the framework adds on top of the bare client: -* Management of events from the Kubernetes API. All events are inserted into a queue by the EventScheduler. The -framework makes sure only the latest event for a certain resource is processed. This is especially important since -on startup the operator can receive a whole series of obsolete events. -* Retrying of failed actions. When an event handler throws an exception the event is put back in the queue. -* A clean interface to the user of the framework to receive events related to the Controller's resource. - -### Dealing with Consistency - -#### Run Single Instance - -There should be always just one instance of an operator running at a time (think process). If there there would be -two ore more, in general it could lead to concurrency issues. Note that we are also doing optimistic locking when we update a resource. -In this way the operator is not highly available. However for operators this not necessary an issue, -if the operator just gets restarted after it went down. - -#### Operator Restarts - -When an operator is started we got events for every resource (of a type we listen to) already on the cluster. Even if the resource is not changed -(We use `kubectl get ... --watch` in the background). This can be a huge amount of resources depending on your use case. -So it could be a good case just have a status field on the resource which is checked, if there anything needs to be done. - -#### At Least Once - -To implement controller logic, we have to override two methods: `createOrUpdateResource` and `deleteResource`. -These methods are called if a resource is create/changed or marked for deletion. In most cases these methods will be -called just once, but in some rare cases can happen that are called more then once. In practice this means that the -implementation needs to be **idempotent**. - -#### Deleting a Resource - -During deletion process we use [Kubernetes finalizers](https://kubernetes.io/docs/tasks/access-kubernetes-api/custom-resources/custom-resource-definitions/#finalizers -"Kubernetes docs") finalizers. This is required, since it can happen that the operator is not running while the delete -of resource is executed (think `oc delete`). In this case we would not catch the delete event. So we automatically add a -finalizer first time we update the resource if its not there. +- [kroxylicious](https://github.com/kroxylicious/kroxylicious/tree/main/kroxylicious-operator) Kafka proxy operator +- [ExposedApp operator](https://github.com/halkyonio/exposedapp-rhdblog): a sample operator + written to illustrate JOSDK concepts and its Quarkus extension in the ["Write Kubernetes + Operators in Java with the Java Operator SDK" blog series](https://developers.redhat.com/articles/2022/02/15/write-kubernetes-java-java-operator-sdk#). +- [Keycloak operator](https://github.com/keycloak/keycloak/tree/main/operator): the official + Keycloak operator, built with Quarkus and JOSDK. +- [Apache Flink Kubernetes operator](https://github.com/apache/flink-kubernetes-operator) is the market leader among Flink operators. +- [Apache Spark Kubernetes Operator](https://github.com/apache/spark-kubernetes-operator) emerging operator for Spark. +- [Strimzi Access operator](https://github.com/strimzi/kafka-access-operator). While the core Strimzi operator development predates + JOSDK, but new components like the Access operator is using the framework. +- [EureKubeOperator](https://medium.com/@heesuk.dev/implementing-kubernetes-operator-for-eureka-service-discovery-integration-by-java-operator-sdk-d21d8087c38e): integrates service discovery of Eureka and Kubernetes using the framework - developed by 11street. It is not released as an open source yet but is very interesting to read about this problem and how it is solved by an operator written with JOSDK. +- [Locust k8s operator](https://github.com/AbdelrhmanHamouda/locust-k8s-operator): Cloud native solution to run performance tests on any Kubernetes cluster. +- [Strimzi Schema Registry Operator](https://github.com/shangyuantech/strimzi-registry-ksql-operator): A Schema Registry Operator based on JOSDK for running the Confluent Schema Registry with a Strimzi-based Kafka cluster. +- [Airflow Dag Operator](https://github.com/cdmikechen/airflow-dag-operator): Use JOSDK(Quarkus Extension) to replace Airflow Git Sync strategy. The main idea of the project is to start a synchronization container on each airflow pod to synchronize the DAG/files into the DAG folder. +- [Glasskube Operator](https://github.com/glasskube/operator): simplifies the deployment, maintenance and upgrade of popular open source business tools. It is written in Kotlin and uses the JOSDK and fabric8 Kubernetes client with Kotlin-based DSL. +- [Debezium Operator](https://github.com/debezium/debezium-operator): Debezium Operator adds Change-Data-Capture capabilities to your Kubernetes or Openshift cluster by providing an easy way to run and manage [Debezium Server](https://debezium.io/documentation/reference/stable/operations/debezium-server.html) instances. diff --git a/adr/001-Introducing-ADRs.md b/adr/001-Introducing-ADRs.md new file mode 100644 index 0000000000..3a432e8094 --- /dev/null +++ b/adr/001-Introducing-ADRs.md @@ -0,0 +1,29 @@ +# Using Architectural Decision Records + +In order to into to document and facilitate discussion over architecture and other design question of the project +we introduce usage of [ADR](https://adr.github.io/). + +In each ADR file, write these sections: + +# Title + +## Status + +What is the status, such as proposed, accepted, rejected, deprecated, superseded, etc.? + +## Context + +What is the issue that we're seeing that is motivating this decision or change? + +## Decision + +What is the change that we're proposing and/or doing? + +## Consequences + +What becomes easier or more difficult to do because of this change? + +## Notes + +Other notes optionally added to the ADR. +Soo other good materials for the ADRs: \ No newline at end of file diff --git a/adr/002-Custom-Resource-Deserialization-Problem.md b/adr/002-Custom-Resource-Deserialization-Problem.md new file mode 100644 index 0000000000..0f648105fc --- /dev/null +++ b/adr/002-Custom-Resource-Deserialization-Problem.md @@ -0,0 +1,44 @@ +# Multi Version Custom Resources Deserialization Problem + +## Status + +accepted + +## Context + +In case there are multiple versions of a custom resource it can happen that a controller/informer tracking +such a resource might run into deserialization problem as shown +in [this integration test](https://github.com/operator-framework/java-operator-sdk/blob/07aab1a9914d865364d7236e496ef9ba5b50699e/operator-framework/src/test/java/io/javaoperatorsdk/operator/MultiVersionCRDIT.java#L55-L55) +. +Such case is possible (as seen in the test) if there are no conversion hooks in place, so the two custom resources +which have different version are stored in the original form (not converted) and are not compatible. +In this case, if there is no further filtering (by labels) informer receives both, but naturally not able to deserialize +one of them. + +How should the framework or the underlying informer behave? + +Alternatives: + +1. The informer should skip the resource and should continue to process the resources with the correct version. +2. Informer stops and makes a notification callback. + +## Decision + +From the JOSDK perspective, it is fine if the informer stops, and the users decides if the whole operator should stop +(usually the preferred way). The reason, that this is an obvious issue on platform level (not on operator/controller +level). Thus, the controller should not receive such custom resources in the first place, so the problem should be +addressed at the platform level. Possibly introducing conversion hooks, or labeling for the target resource. + +## Consequences + +If an Informer stops on such deserialization error, even explicitly restarting it won't solve the problem, since +would fail again on the same error. + +## Notes + +- The informer implementation in fabric8 client changed in this regard, before it was not stopping on deserialization + error, but as described this change in behavior is completely acceptable. + +- the deserializer can be set to be more lenient by configuring the Serialization Unmatched Field Type module: + `Serialization.UNMATCHED_FIELD_TYPE_MODULE.setRestrictToTemplates(true);`. In general is not desired to + process custom resources that are not deserialized correctly. \ No newline at end of file diff --git a/bootstrapper-maven-plugin/pom.xml b/bootstrapper-maven-plugin/pom.xml new file mode 100644 index 0000000000..c306dcea35 --- /dev/null +++ b/bootstrapper-maven-plugin/pom.xml @@ -0,0 +1,100 @@ + + + 4.0.0 + + + io.javaoperatorsdk + java-operator-sdk + 5.1.5-SNAPSHOT + + + bootstrapper + maven-plugin + Operator SDK - Bootstrapper Maven Plugin + Operator SDK - Bootstrapper Maven Plugin + + + 3.15.1 + 3.9.11 + 3.0.0 + 3.15.1 + + + + + org.apache.maven + maven-plugin-api + ${maven-plugin-api.version} + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + ${maven-plugin-annotations.version} + provided + + + org.slf4j + slf4j-api + + + org.apache.logging.log4j + log4j-slf4j2-impl + test + + + org.apache.logging.log4j + log4j-core + test + + + org.junit.jupiter + junit-jupiter-api + + + org.junit.jupiter + junit-jupiter-engine + + + commons-io + commons-io + 2.20.0 + + + com.github.spullara.mustache.java + compiler + + + org.assertj + assertj-core + test + + + + + + + org.apache.maven.plugins + maven-plugin-plugin + ${maven-plugin-plugin.version} + + josdk-bootstrapper + + + + org.codehaus.mojo + templating-maven-plugin + ${templating-maven-plugin.version} + + + filtering-java-templates + + filter-sources + + + + + + + + diff --git a/bootstrapper-maven-plugin/src/main/java-templates/io/javaoperatorsdk/operator/bootstrapper/Versions.java b/bootstrapper-maven-plugin/src/main/java-templates/io/javaoperatorsdk/operator/bootstrapper/Versions.java new file mode 100644 index 0000000000..1656ee28f7 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/java-templates/io/javaoperatorsdk/operator/bootstrapper/Versions.java @@ -0,0 +1,10 @@ +package io.javaoperatorsdk.bootstrapper; + +public final class Versions { + + private Versions() {} + + public static final String JOSDK = "${project.version}"; + public static final String KUBERNETES_CLIENT = "${fabric8-client.version}"; + +} diff --git a/bootstrapper-maven-plugin/src/main/java/io/javaoperatorsdk/boostrapper/Bootstrapper.java b/bootstrapper-maven-plugin/src/main/java/io/javaoperatorsdk/boostrapper/Bootstrapper.java new file mode 100644 index 0000000000..7339d7e9aa --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/java/io/javaoperatorsdk/boostrapper/Bootstrapper.java @@ -0,0 +1,157 @@ +package io.javaoperatorsdk.boostrapper; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.commons.io.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.bootstrapper.Versions; + +import com.github.mustachejava.DefaultMustacheFactory; +import com.github.mustachejava.MustacheFactory; + +public class Bootstrapper { + + private static final Logger log = LoggerFactory.getLogger(Bootstrapper.class); + + private final MustacheFactory mustacheFactory = new DefaultMustacheFactory(); + + // .gitignore gets excluded from resource, using here a prefixed version + private static final Map TOP_LEVEL_STATIC_FILES = + Map.of("_.gitignore", ".gitignore", "README.md", "README.md"); + private static final List JAVA_FILES = + List.of("CustomResource.java", "Reconciler.java", "Spec.java", "Status.java"); + + public void create(File targetDir, String groupId, String artifactId) { + try { + log.info("Generating project to: {}", targetDir.getPath()); + var projectDir = new File(targetDir, artifactId); + FileUtils.forceMkdir(projectDir); + addStaticFiles(projectDir); + addTemplatedFiles(projectDir, groupId, artifactId); + addJavaFiles(projectDir, groupId, artifactId); + addResourceFiles(projectDir, groupId, artifactId); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void addResourceFiles(File projectDir, String groupId, String artifactId) { + try { + var target = new File(projectDir, "src/main/resources"); + FileUtils.forceMkdir(target); + addTemplatedFile(target, "log4j2.xml", groupId, artifactId, target, null); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void addJavaFiles(File projectDir, String groupId, String artifactId) { + try { + var packages = groupId.replace(".", File.separator); + var targetDir = new File(projectDir, "src/main/java/" + packages); + var targetTestDir = new File(projectDir, "src/test/java/" + packages); + FileUtils.forceMkdir(targetDir); + var classFileNamePrefix = artifactClassId(artifactId); + JAVA_FILES.forEach( + f -> + addTemplatedFile( + projectDir, f, groupId, artifactId, targetDir, classFileNamePrefix + f)); + + addTemplatedFile(projectDir, "Runner.java", groupId, artifactId, targetDir, null); + addTemplatedFile( + projectDir, "ConfigMapDependentResource.java", groupId, artifactId, targetDir, null); + addTemplatedFile( + projectDir, + "ReconcilerIntegrationTest.java", + groupId, + artifactId, + targetTestDir, + artifactClassId(artifactId) + "ReconcilerIntegrationTest.java"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void addTemplatedFiles(File projectDir, String groupId, String artifactId) { + addTemplatedFile(projectDir, "pom.xml", groupId, artifactId); + addTemplatedFile(projectDir, "k8s/test-resource.yaml", groupId, artifactId); + } + + private void addTemplatedFile( + File projectDir, String fileName, String groupId, String artifactId) { + addTemplatedFile(projectDir, fileName, groupId, artifactId, null, null); + } + + private void addTemplatedFile( + File projectDir, + String fileName, + String groupId, + String artifactId, + File targetDir, + String targetFileName) { + try { + var values = + Map.of( + "groupId", + groupId, + "artifactId", + artifactId, + "artifactClassId", + artifactClassId(artifactId), + "josdkVersion", + Versions.JOSDK, + "fabric8Version", + Versions.KUBERNETES_CLIENT); + + var mustache = mustacheFactory.compile("templates/" + fileName); + var targetFile = + new File( + targetDir == null ? projectDir : targetDir, + targetFileName == null ? fileName : targetFileName); + FileUtils.forceMkdir(targetFile.getParentFile()); + var writer = new FileWriter(targetFile); + mustache.execute(writer, values); + writer.flush(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void addStaticFiles(File projectDir) { + TOP_LEVEL_STATIC_FILES.forEach((key, value) -> addStaticFile(projectDir, key, value)); + } + + private void addStaticFile(File targetDir, String fileName, String targetFileName) { + addStaticFile(targetDir, fileName, targetFileName, null); + } + + private void addStaticFile( + File targetDir, String fileName, String targetFilename, String subDir) { + String sourcePath = subDir == null ? "/static/" : "/static/" + subDir; + String path = sourcePath + fileName; + try (var is = Bootstrapper.class.getResourceAsStream(path)) { + targetDir = subDir == null ? targetDir : new File(targetDir, subDir); + if (subDir != null) { + FileUtils.forceMkdir(targetDir); + } + FileUtils.copyInputStreamToFile(is, new File(targetDir, targetFilename)); + } catch (IOException e) { + throw new RuntimeException("File path: " + path, e); + } + } + + public static String artifactClassId(String artifactId) { + var parts = artifactId.split("-"); + return Arrays.stream(parts) + .map(p -> p.substring(0, 1).toUpperCase() + p.substring(1)) + .collect(Collectors.joining("")); + } +} diff --git a/bootstrapper-maven-plugin/src/main/java/io/javaoperatorsdk/boostrapper/BootstrapperMojo.java b/bootstrapper-maven-plugin/src/main/java/io/javaoperatorsdk/boostrapper/BootstrapperMojo.java new file mode 100644 index 0000000000..cb470f7e87 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/java/io/javaoperatorsdk/boostrapper/BootstrapperMojo.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.boostrapper; + +import java.io.File; + +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +@Mojo(name = "create", requiresProject = false) +public class BootstrapperMojo extends AbstractMojo { + + @Parameter(defaultValue = "${projectGroupId}") + protected String projectGroupId; + + @Parameter(defaultValue = "${projectArtifactId}") + protected String projectArtifactId; + + public void execute() throws MojoExecutionException { + String userDir = System.getProperty("user.dir"); + new Bootstrapper().create(new File(userDir), projectGroupId, projectArtifactId); + } +} diff --git a/bootstrapper-maven-plugin/src/main/resources/log4j2.xml b/bootstrapper-maven-plugin/src/main/resources/log4j2.xml new file mode 100644 index 0000000000..124aef7838 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/bootstrapper-maven-plugin/src/main/resources/static/README.md b/bootstrapper-maven-plugin/src/main/resources/static/README.md new file mode 100644 index 0000000000..7746a9a5d2 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/static/README.md @@ -0,0 +1,3 @@ +# Generated Project Skeleton + +A simple operator that copies the value in a spec to a ConfigMap. \ No newline at end of file diff --git a/bootstrapper-maven-plugin/src/main/resources/static/_.gitignore b/bootstrapper-maven-plugin/src/main/resources/static/_.gitignore new file mode 100644 index 0000000000..9a6a0350f2 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/static/_.gitignore @@ -0,0 +1,41 @@ +#Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties +.flattened-pom.xml + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Local environment +.env + diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/ConfigMapDependentResource.java b/bootstrapper-maven-plugin/src/main/resources/templates/ConfigMapDependentResource.java new file mode 100644 index 0000000000..59eae8b01c --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/templates/ConfigMapDependentResource.java @@ -0,0 +1,32 @@ +package {{groupId}}; + +import java.util.HashMap; +import java.util.Map; + +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.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import {{groupId}}.{{artifactClassId}}CustomResource; + +@KubernetesDependent +public class ConfigMapDependentResource + extends CRUDKubernetesDependentResource { + + public static final String KEY = "key"; + + @Override + protected ConfigMap desired({{artifactClassId}}CustomResource primary, + Context<{{artifactClassId}}CustomResource> context) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of(KEY, primary.getSpec().getValue())) + .build(); + } +} \ No newline at end of file diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/CustomResource.java b/bootstrapper-maven-plugin/src/main/resources/templates/CustomResource.java new file mode 100644 index 0000000000..e17dcc0450 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/templates/CustomResource.java @@ -0,0 +1,11 @@ +package {{groupId}}; + +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("{{groupId}}") +@Version("v1") +public class {{artifactClassId}}CustomResource extends CustomResource<{{artifactClassId}}Spec,{{artifactClassId}}Status> implements Namespaced { +} diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/Reconciler.java b/bootstrapper-maven-plugin/src/main/resources/templates/Reconciler.java new file mode 100644 index 0000000000..f7583be4ee --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/templates/Reconciler.java @@ -0,0 +1,20 @@ +package {{groupId}}; + +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.api.reconciler.Workflow; + +import java.util.Map; +import java.util.Optional; + +@Workflow(dependents = {@Dependent(type = ConfigMapDependentResource.class)}) +public class {{artifactClassId}}Reconciler implements Reconciler<{{artifactClassId}}CustomResource> { + + public UpdateControl<{{artifactClassId}}CustomResource> reconcile({{artifactClassId}}CustomResource primary, + Context<{{artifactClassId}}CustomResource> context) { + + return UpdateControl.noUpdate(); + } +} diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/ReconcilerIntegrationTest.java b/bootstrapper-maven-plugin/src/main/resources/templates/ReconcilerIntegrationTest.java new file mode 100644 index 0000000000..865fe9c594 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/templates/ReconcilerIntegrationTest.java @@ -0,0 +1,60 @@ +package {{groupId}}; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static {{groupId}}.ConfigMapDependentResource.KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class {{artifactClassId}}ReconcilerIntegrationTest { + + public static final String RESOURCE_NAME = "test1"; + public static final String INITIAL_VALUE = "initial value"; + public static final String CHANGED_VALUE = "changed value"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler({{artifactClassId}}Reconciler.class) + .build(); + + @Test + void testCRUDOperations() { + var cr = extension.create(testResource()); + + await().untilAsserted(() -> { + var cm = extension.get(ConfigMap.class, RESOURCE_NAME); + assertThat(cm).isNotNull(); + assertThat(cm.getData()).containsEntry(KEY, INITIAL_VALUE); + }); + + cr.getSpec().setValue(CHANGED_VALUE); + cr = extension.replace(cr); + + await().untilAsserted(() -> { + var cm = extension.get(ConfigMap.class, RESOURCE_NAME); + assertThat(cm.getData()).containsEntry(KEY, CHANGED_VALUE); + }); + + extension.delete(cr); + + await().untilAsserted(() -> { + var cm = extension.get(ConfigMap.class, RESOURCE_NAME); + assertThat(cm).isNull(); + }); + } + + {{artifactClassId}}CustomResource testResource() { + var resource = new {{artifactClassId}}CustomResource(); + resource.setMetadata(new ObjectMetaBuilder() + .withName(RESOURCE_NAME) + .build()); + resource.setSpec(new {{artifactClassId}}Spec()); + resource.getSpec().setValue(INITIAL_VALUE); + return resource; + } +} diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/Runner.java b/bootstrapper-maven-plugin/src/main/resources/templates/Runner.java new file mode 100644 index 0000000000..41be6d6976 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/templates/Runner.java @@ -0,0 +1,18 @@ +package {{groupId}}; + +import io.javaoperatorsdk.operator.Operator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + +public class Runner { + + private static final Logger log = LoggerFactory.getLogger(Runner.class); + + public static void main(String[] args) { + Operator operator = new Operator(); + operator.register(new {{artifactClassId}}Reconciler()); + operator.start(); + log.info("Operator started."); + } +} diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/Spec.java b/bootstrapper-maven-plugin/src/main/resources/templates/Spec.java new file mode 100644 index 0000000000..13d82dad51 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/templates/Spec.java @@ -0,0 +1,14 @@ +package {{groupId}}; + +public class {{artifactClassId}}Spec { + + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/Status.java b/bootstrapper-maven-plugin/src/main/resources/templates/Status.java new file mode 100644 index 0000000000..52bd0fd4d2 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/templates/Status.java @@ -0,0 +1,5 @@ +package {{groupId}}; + +public class {{artifactClassId}}Status { + +} diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/k8s/test-resource.yaml b/bootstrapper-maven-plugin/src/main/resources/templates/k8s/test-resource.yaml new file mode 100644 index 0000000000..ec7987512e --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/templates/k8s/test-resource.yaml @@ -0,0 +1,6 @@ +apiVersion: {{groupId}}/v1 +kind: {{artifactClassId}}CustomResource +metadata: + name: test1 +spec: + value: test \ No newline at end of file diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/log4j2.xml b/bootstrapper-maven-plugin/src/main/resources/templates/log4j2.xml new file mode 100644 index 0000000000..9fde311940 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/templates/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml b/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml new file mode 100644 index 0000000000..09e8ed0ef8 --- /dev/null +++ b/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + {{groupId}} + {{artifactId}} + 0.1.0-SNAPSHOT + sample-webpage-operator + jar + + + 17 + ${java.version} + ${java.version} + {{josdkVersion}} + 2.0.17 + 5.9.2 + 2.20.0 + {{fabric8Version}} + + + + + + io.javaoperatorsdk + operator-framework-bom + ${josdk.version} + pom + import + + + + + + + io.javaoperatorsdk + operator-framework + ${josdk.version} + + + io.javaoperatorsdk + operator-framework-junit-5 + ${josdk.version} + test + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.apache.logging.log4j + log4j-slf4j2-impl + ${log4j.version} + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + + + org.junit.jupiter + junit-jupiter-api + test + ${junit.version} + + + org.junit.jupiter + junit-jupiter-engine + test + ${junit.version} + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + io.fabric8 + crd-generator-maven-plugin + ${fabric8-client.version} + + + + generate + + + + + + + + diff --git a/bootstrapper-maven-plugin/src/test/java/io/javaoperatorsdk/bootstrapper/BootstrapperTest.java b/bootstrapper-maven-plugin/src/test/java/io/javaoperatorsdk/bootstrapper/BootstrapperTest.java new file mode 100644 index 0000000000..f7840c1585 --- /dev/null +++ b/bootstrapper-maven-plugin/src/test/java/io/javaoperatorsdk/bootstrapper/BootstrapperTest.java @@ -0,0 +1,53 @@ +package io.javaoperatorsdk.bootstrapper; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.boostrapper.Bootstrapper; + +import static org.assertj.core.api.Assertions.assertThat; + +class BootstrapperTest { + + private static final Logger log = LoggerFactory.getLogger(BootstrapperTest.class); + + Bootstrapper bootstrapper = new Bootstrapper(); + + @Test + void copiesFilesToTarget() { + bootstrapper.create(new File("target"), "io.sample", "test-project"); + + var targetDir = new File("target", "test-project"); + assertThat(targetDir.list()).contains("pom.xml"); + assertProjectCompiles(); + } + + private void assertProjectCompiles() { + try { + var process = + Runtime.getRuntime() + .exec( + "mvn clean install -f target/test-project/pom.xml -DskipTests" + + " -Dspotless.apply.skip"); + + BufferedReader stdOut = new BufferedReader(new InputStreamReader(process.getInputStream())); + + log.info("Maven output:"); + String logLine; + while ((logLine = stdOut.readLine()) != null) { + log.info(logLine); + } + var res = process.waitFor(); + log.info("exit code: {}", res); + assertThat(res).isZero(); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml new file mode 100644 index 0000000000..76f3db9abc --- /dev/null +++ b/caffeine-bounded-cache-support/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + + io.javaoperatorsdk + java-operator-sdk + 5.1.5-SNAPSHOT + + + caffeine-bounded-cache-support + Operator SDK - Caffeine Bounded Cache Support + + + + io.javaoperatorsdk + operator-framework-core + + + com.github.ben-manes.caffeine + caffeine + + + io.javaoperatorsdk + operator-framework + test + + + io.javaoperatorsdk + operator-framework-junit-5 + ${project.version} + test + + + org.apache.logging.log4j + log4j-slf4j2-impl + test + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + test + + + io.fabric8 + kubernetes-httpclient-okhttp + test + + + + + + + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + + default-compile + + compile + + compile + + + -proc:none + + + + + + + io.fabric8 + crd-generator-maven-plugin + ${fabric8-client.version} + + + + generate + + process-test-classes + + ${project.build.testOutputDirectory} + WITH_ALL_DEPENDENCIES_AND_TESTS + + + + + + + + diff --git a/caffeine-bounded-cache-support/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedCache.java b/caffeine-bounded-cache-support/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedCache.java new file mode 100644 index 0000000000..c7ac96cb20 --- /dev/null +++ b/caffeine-bounded-cache-support/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedCache.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache; + +import com.github.benmanes.caffeine.cache.Cache; + +/** Caffeine cache wrapper to be used in a {@link BoundedItemStore} */ +public class CaffeineBoundedCache implements BoundedCache { + + private final Cache cache; + + public CaffeineBoundedCache(Cache cache) { + this.cache = cache; + } + + @Override + public R get(K key) { + return cache.getIfPresent(key); + } + + @Override + public R remove(K key) { + var value = cache.getIfPresent(key); + cache.invalidate(key); + return value; + } + + @Override + public void put(K key, R object) { + cache.put(key, object); + } +} diff --git a/caffeine-bounded-cache-support/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedItemStores.java b/caffeine-bounded-cache-support/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedItemStores.java new file mode 100644 index 0000000000..89fbcef70f --- /dev/null +++ b/caffeine-bounded-cache-support/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedItemStores.java @@ -0,0 +1,51 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache; + +import java.time.Duration; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +/** + * A factory for Caffeine-backed {@link + * BoundedItemStore}. The implementation uses a {@link CaffeineBoundedCache} to store resources and + * progressively evict them if they haven't been used in a while. The idea about + * CaffeinBoundedItemStore-s is that, caffeine will cache the resources which were recently used, + * and will evict resource, which are not used for a while. This is ideal for startup performance + * and efficiency when all resources should be cached to avoid undue load on the API server. This is + * why setting a maximal cache size is not practical and the approach of evicting least recently + * used resources was chosen. However, depending on controller implementations and domains, it could + * happen that some / many of these resources are then seldom or even reconciled anymore. In that + * situation, large amounts of memory might be consumed to cache resources that are never used + * again. + * + *

Note that if a resource is reconciled and is not present anymore in cache, it will + * transparently be fetched again from the API server. Similarly, since associated secondary + * resources are usually reconciled too, they might need to be fetched and populated to the cache, + * and will remain there for some time, for subsequent reconciliations. + */ +public class CaffeineBoundedItemStores { + + private CaffeineBoundedItemStores() {} + + /** + * @param client Kubernetes Client + * @param rClass resource class + * @param accessExpireDuration the duration after resources is evicted from cache if not accessed. + * @return the ItemStore implementation + * @param resource type + */ + @SuppressWarnings("unused") + public static BoundedItemStore boundedItemStore( + KubernetesClient client, Class rClass, Duration accessExpireDuration) { + Cache cache = Caffeine.newBuilder().expireAfterAccess(accessExpireDuration).build(); + return boundedItemStore(client, rClass, cache); + } + + public static BoundedItemStore boundedItemStore( + KubernetesClient client, Class rClass, Cache cache) { + return new BoundedItemStore<>(new CaffeineBoundedCache<>(cache), rClass, client); + } +} diff --git a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedCacheTestBase.java b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedCacheTestBase.java new file mode 100644 index 0000000000..532e5237f8 --- /dev/null +++ b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedCacheTestBase.java @@ -0,0 +1,107 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache; + +import java.time.Duration; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.client.CustomResource; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope.BoundedCacheTestSpec; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope.BoundedCacheTestStatus; + +import static io.javaoperatorsdk.operator.processing.event.source.cache.sample.AbstractTestReconciler.DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public abstract class BoundedCacheTestBase< + P extends CustomResource> { + + private static final Logger log = LoggerFactory.getLogger(BoundedCacheTestBase.class); + + public static final int NUMBER_OF_RESOURCE_TO_TEST = 3; + public static final String RESOURCE_NAME_PREFIX = "test-"; + public static final String INITIAL_DATA_PREFIX = "data-"; + public static final String UPDATED_PREFIX = "updatedPrefix"; + + @Test + void reconciliationWorksWithLimitedCache() { + createTestResources(); + + assertConfigMapData(INITIAL_DATA_PREFIX); + + updateTestResources(); + + assertConfigMapData(UPDATED_PREFIX); + + deleteTestResources(); + + assertConfigMapsDeleted(); + } + + private void assertConfigMapsDeleted() { + await() + .atMost(Duration.ofSeconds(120)) + .untilAsserted( + () -> + IntStream.range(0, NUMBER_OF_RESOURCE_TO_TEST) + .forEach( + i -> { + var cm = extension().get(ConfigMap.class, RESOURCE_NAME_PREFIX + i); + assertThat(cm).isNull(); + })); + } + + private void deleteTestResources() { + IntStream.range(0, NUMBER_OF_RESOURCE_TO_TEST) + .forEach( + i -> { + var cm = extension().get(customResourceClass(), RESOURCE_NAME_PREFIX + i); + var deleted = extension().delete(cm); + if (!deleted) { + log.warn("Custom resource might not be deleted: {}", cm); + } + }); + } + + private void updateTestResources() { + IntStream.range(0, NUMBER_OF_RESOURCE_TO_TEST) + .forEach( + i -> { + var cm = extension().get(ConfigMap.class, RESOURCE_NAME_PREFIX + i); + cm.getData().put(DATA_KEY, UPDATED_PREFIX + i); + extension().replace(cm); + }); + } + + void assertConfigMapData(String dataPrefix) { + await() + .untilAsserted( + () -> + IntStream.range(0, NUMBER_OF_RESOURCE_TO_TEST) + .forEach(i -> assertConfigMap(i, dataPrefix))); + } + + private void assertConfigMap(int i, String prefix) { + var cm = extension().get(ConfigMap.class, RESOURCE_NAME_PREFIX + i); + assertThat(cm).isNotNull(); + assertThat(cm.getData().get(DATA_KEY)).isEqualTo(prefix + i); + } + + private void createTestResources() { + IntStream.range(0, NUMBER_OF_RESOURCE_TO_TEST) + .forEach( + i -> { + extension().create(createTestResource(i)); + }); + } + + abstract P createTestResource(int index); + + abstract Class

customResourceClass(); + + abstract LocallyRunOperatorExtension extension(); +} diff --git a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedCacheClusterScopeIT.java b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedCacheClusterScopeIT.java new file mode 100644 index 0000000000..0c16c1227b --- /dev/null +++ b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedCacheClusterScopeIT.java @@ -0,0 +1,53 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache; + +import java.time.Duration; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.clusterscope.BoundedCacheClusterScopeTestCustomResource; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.clusterscope.BoundedCacheClusterScopeTestReconciler; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope.BoundedCacheTestSpec; + +import static io.javaoperatorsdk.operator.processing.event.source.cache.sample.AbstractTestReconciler.boundedItemStore; + +public class CaffeineBoundedCacheClusterScopeIT + extends BoundedCacheTestBase { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler( + new BoundedCacheClusterScopeTestReconciler(), + o -> { + o.withItemStore( + boundedItemStore( + new KubernetesClientBuilder().build(), + BoundedCacheClusterScopeTestCustomResource.class, + Duration.ofMinutes(1), + 1)); + }) + .build(); + + @Override + BoundedCacheClusterScopeTestCustomResource createTestResource(int index) { + var res = new BoundedCacheClusterScopeTestCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME_PREFIX + index).build()); + res.setSpec(new BoundedCacheTestSpec()); + res.getSpec().setData(INITIAL_DATA_PREFIX + index); + res.getSpec().setTargetNamespace(extension.getNamespace()); + return res; + } + + @Override + Class customResourceClass() { + return BoundedCacheClusterScopeTestCustomResource.class; + } + + @Override + LocallyRunOperatorExtension extension() { + return extension; + } +} diff --git a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedCacheNamespacedIT.java b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedCacheNamespacedIT.java new file mode 100644 index 0000000000..534d7b2027 --- /dev/null +++ b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedCacheNamespacedIT.java @@ -0,0 +1,52 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache; + +import java.time.Duration; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope.BoundedCacheTestCustomResource; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope.BoundedCacheTestReconciler; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope.BoundedCacheTestSpec; + +import static io.javaoperatorsdk.operator.processing.event.source.cache.sample.AbstractTestReconciler.boundedItemStore; + +class CaffeineBoundedCacheNamespacedIT + extends BoundedCacheTestBase { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler( + new BoundedCacheTestReconciler(), + o -> { + o.withItemStore( + boundedItemStore( + new KubernetesClientBuilder().build(), + BoundedCacheTestCustomResource.class, + Duration.ofMinutes(1), + 1)); + }) + .build(); + + BoundedCacheTestCustomResource createTestResource(int index) { + var res = new BoundedCacheTestCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME_PREFIX + index).build()); + res.setSpec(new BoundedCacheTestSpec()); + res.getSpec().setData(INITIAL_DATA_PREFIX + index); + res.getSpec().setTargetNamespace(extension.getNamespace()); + return res; + } + + @Override + Class customResourceClass() { + return BoundedCacheTestCustomResource.class; + } + + @Override + LocallyRunOperatorExtension extension() { + return extension; + } +} diff --git a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/AbstractTestReconciler.java b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/AbstractTestReconciler.java new file mode 100644 index 0000000000..b059ac033b --- /dev/null +++ b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/AbstractTestReconciler.java @@ -0,0 +1,118 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache.sample; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +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.cache.BoundedItemStore; +import io.javaoperatorsdk.operator.processing.event.source.cache.CaffeineBoundedItemStores; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.clusterscope.BoundedCacheClusterScopeTestReconciler; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope.BoundedCacheTestSpec; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope.BoundedCacheTestStatus; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +public abstract class AbstractTestReconciler< + P extends CustomResource> + implements Reconciler

{ + + private static final Logger log = + LoggerFactory.getLogger(BoundedCacheClusterScopeTestReconciler.class); + + public static final String DATA_KEY = "dataKey"; + + @Override + public UpdateControl

reconcile(P resource, Context

context) { + var maybeConfigMap = context.getSecondaryResource(ConfigMap.class); + maybeConfigMap.ifPresentOrElse( + cm -> updateConfigMapIfNeeded(cm, resource, context), + () -> createConfigMap(resource, context)); + ensureStatus(resource); + log.info("Reconciled: {}", resource.getMetadata().getName()); + return UpdateControl.patchStatus(resource); + } + + protected void updateConfigMapIfNeeded(ConfigMap cm, P resource, Context

context) { + var data = cm.getData().get(DATA_KEY); + if (data == null || data.equals(resource.getSpec().getData())) { + cm.setData(Map.of(DATA_KEY, resource.getSpec().getData())); + context.getClient().configMaps().resource(cm).replace(); + } + } + + protected void createConfigMap(P resource, Context

context) { + var cm = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getSpec().getTargetNamespace()) + .build()) + .withData(Map.of(DATA_KEY, resource.getSpec().getData())) + .build(); + cm.addOwnerReference(resource); + context.getClient().configMaps().resource(cm).create(); + } + + @Override + public List> prepareEventSources(EventSourceContext

context) { + + var boundedItemStore = + boundedItemStore( + new KubernetesClientBuilder().build(), + ConfigMap.class, + Duration.ofMinutes(1), + 1); // setting max size for testing purposes + + var es = + new InformerEventSource<>( + InformerEventSourceConfiguration.from(ConfigMap.class, primaryClass()) + .withItemStore(boundedItemStore) + .withSecondaryToPrimaryMapper( + Mappers.fromOwnerReferences( + context.getPrimaryResourceClass(), + this instanceof BoundedCacheClusterScopeTestReconciler)) + .build(), + context); + + return List.of(es); + } + + private void ensureStatus(P resource) { + if (resource.getStatus() == null) { + resource.setStatus(new BoundedCacheTestStatus()); + } + } + + public static BoundedItemStore boundedItemStore( + KubernetesClient client, + Class rClass, + Duration accessExpireDuration, + // max size is only for testing purposes + long cacheMaxSize) { + Cache cache = + Caffeine.newBuilder() + .expireAfterAccess(accessExpireDuration) + .maximumSize(cacheMaxSize) + .build(); + return CaffeineBoundedItemStores.boundedItemStore(client, rClass, cache); + } + + protected abstract Class

primaryClass(); +} diff --git a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/clusterscope/BoundedCacheClusterScopeTestCustomResource.java b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/clusterscope/BoundedCacheClusterScopeTestCustomResource.java new file mode 100644 index 0000000000..6fc9a5babc --- /dev/null +++ b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/clusterscope/BoundedCacheClusterScopeTestCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache.sample.clusterscope; + +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; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope.BoundedCacheTestSpec; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope.BoundedCacheTestStatus; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("bccs") +public class BoundedCacheClusterScopeTestCustomResource + extends CustomResource {} diff --git a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/clusterscope/BoundedCacheClusterScopeTestReconciler.java b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/clusterscope/BoundedCacheClusterScopeTestReconciler.java new file mode 100644 index 0000000000..93f103cbf2 --- /dev/null +++ b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/clusterscope/BoundedCacheClusterScopeTestReconciler.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache.sample.clusterscope; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.AbstractTestReconciler; + +@ControllerConfiguration +public class BoundedCacheClusterScopeTestReconciler + extends AbstractTestReconciler { + + @Override + protected Class primaryClass() { + return BoundedCacheClusterScopeTestCustomResource.class; + } +} diff --git a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestCustomResource.java b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestCustomResource.java new file mode 100644 index 0000000000..9b77aa7bf8 --- /dev/null +++ b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope; + +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("bct") +public class BoundedCacheTestCustomResource + extends CustomResource implements Namespaced {} diff --git a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestReconciler.java b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestReconciler.java new file mode 100644 index 0000000000..6b95665585 --- /dev/null +++ b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestReconciler.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope; + +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.processing.event.source.cache.sample.AbstractTestReconciler; + +@ControllerConfiguration +public class BoundedCacheTestReconciler + extends AbstractTestReconciler { + + @Override + protected Class primaryClass() { + return BoundedCacheTestCustomResource.class; + } +} diff --git a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestSpec.java b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestSpec.java new file mode 100644 index 0000000000..63e5876267 --- /dev/null +++ b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestSpec.java @@ -0,0 +1,25 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope; + +public class BoundedCacheTestSpec { + + private String data; + private String targetNamespace; + + public String getData() { + return data; + } + + public BoundedCacheTestSpec setData(String data) { + this.data = data; + return this; + } + + public String getTargetNamespace() { + return targetNamespace; + } + + public BoundedCacheTestSpec setTargetNamespace(String targetNamespace) { + this.targetNamespace = targetNamespace; + return this; + } +} diff --git a/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestStatus.java b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestStatus.java new file mode 100644 index 0000000000..5aa5ca2258 --- /dev/null +++ b/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/namespacescope/BoundedCacheTestStatus.java @@ -0,0 +1,3 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache.sample.namespacescope; + +public class BoundedCacheTestStatus {} diff --git a/caffeine-bounded-cache-support/src/test/resources/log4j2.xml b/caffeine-bounded-cache-support/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..f23cf772dd --- /dev/null +++ b/caffeine-bounded-cache-support/src/test/resources/log4j2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000000..40b67f41a7 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,5 @@ +/public +resources/ +node_modules/ +package-lock.json +.hugo_build.lock \ No newline at end of file diff --git a/docs/.nvmrc b/docs/.nvmrc new file mode 100644 index 0000000000..b009dfb9d9 --- /dev/null +++ b/docs/.nvmrc @@ -0,0 +1 @@ +lts/* diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000000..5ea571c69d --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing to Java Operator SDK Documentation + +Thank you for your interest in improving the Java Operator SDK documentation! We welcome contributions from the community and appreciate your help in making our documentation better. + +## How to Contribute + +### Getting Started + +1. **Fork the repository** and clone your fork locally +2. **Create a new branch** for your changes +3. **Make your improvements** to the documentation +4. **Test your changes** locally using `hugo server` +5. **Submit a pull request** with a clear description of your changes + +### Types of Contributions + +We welcome various types of contributions: + +- **Content improvements**: Fix typos, clarify explanations, add examples +- **New documentation**: Add missing sections or entirely new guides +- **Structural improvements**: Better organization, navigation, or formatting +- **Translation**: Help translate documentation to other languages + +## Guidelines + +### Writing Style + +- Use clear, concise language +- Write in active voice when possible +- Define technical terms when first used +- Include practical examples where helpful +- Keep sentences and paragraphs reasonably short + +### Technical Requirements + +- Test all code examples to ensure they work +- Use proper markdown formatting +- Follow existing documentation structure and conventions +- Ensure links work and point to current resources + +## Legal Requirements + +### Contributor License Agreement + +All contributions must be accompanied by a Contributor License Agreement (CLA). You (or your employer) retain the copyright to your contribution; the CLA simply gives us permission to use and redistribute your contributions as part of the project. + +Visit to see your current agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one (even for a different project), you probably don't need to do it again. + +### Code Review Process + +All submissions, including those by project members, require review. We use GitHub pull requests for this purpose. Please consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests. + +## Community Guidelines + +This project follows [Google's Open Source Community Guidelines](https://opensource.google.com/conduct/). diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 0000000000..232d8f70c4 --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,4 @@ +FROM floryn90/hugo:ext-alpine + +RUN apk add git && \ + git config --global --add safe.directory /src diff --git a/docs/LICENSE b/docs/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/docs/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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/docs/README.md b/docs/README.md new file mode 100644 index 0000000000..14f675b53b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,82 @@ +# Java Operator SDK Documentation + +This repository contains the documentation website for the Java Operator SDK (JOSDK), built using Hugo and the Docsy theme. + +## About Java Operator SDK + +Java Operator SDK is a framework that makes it easy to build Kubernetes operators in Java. It provides APIs designed to feel natural to Java developers and handles common operator challenges automatically, allowing you to focus on your business logic. + +## Development Setup + +This documentation site uses Hugo v0.125.7 with the Docsy theme. + +## Prerequisites + +- Hugo v0.125.7 or later (extended version required) +- Node.js and npm (for PostCSS processing) +- Git + +## Local Development + +### Quick Start + +1. Clone this repository +2. Install dependencies: + ```bash + npm install + ``` +3. Start the development server: + ```bash + hugo server + ``` +4. Open your browser to `http://localhost:1313` + +### Using Docker + +You can also run the documentation site using Docker: + +1. Build the container: + ```bash + docker-compose build + ``` +2. Run the container: + ```bash + docker-compose up + ``` + > **Note**: You can combine both commands with `docker-compose up --build` + +3. Access the site at `http://localhost:1313` + +To stop the container, press **Ctrl + C** in your terminal. + +To clean up Docker resources: +```bash +docker-compose rm +``` + +## Contributing + +We welcome contributions to improve the documentation! Please see our [contribution guidelines](CONTRIBUTING.md) for details on how to get started. + +## Troubleshooting + +### Module Compatibility Error +If you see an error about module compatibility, ensure you're using Hugo v0.110.0 or higher: +```console +Error: Error building site: failed to extract shortcode: template for shortcode "blocks/cover" not found +``` + +### SCSS Processing Error +If you encounter SCSS-related errors, make sure you have the extended version of Hugo installed: +```console +Error: TOCSS: failed to transform "scss/main.scss" +``` + +### Go Binary Not Found +If you see "binary with name 'go' not found", install the Go programming language from [golang.org](https://golang.org). + +## Links + +- [Hugo Documentation](https://gohugo.io/documentation/) +- [Docsy Theme Documentation](https://www.docsy.dev/docs/) +- [Java Operator SDK GitHub Repository](https://github.com/operator-framework/java-operator-sdk) diff --git a/docs/assets/icons/logo.svg b/docs/assets/icons/logo.svg new file mode 100644 index 0000000000..0048fdf4d6 --- /dev/null +++ b/docs/assets/icons/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/scss/_variables_project.scss b/docs/assets/scss/_variables_project.scss new file mode 100644 index 0000000000..35523dc3f4 --- /dev/null +++ b/docs/assets/scss/_variables_project.scss @@ -0,0 +1,10 @@ +/* + +Add styles or override variables from the theme here. + +*/ + +//$primary: #fc9c62;; +$primary: #da5504; +$secondary: #fc9c62 +//$secondary: white; \ No newline at end of file diff --git a/docs/config.yaml b/docs/config.yaml new file mode 100644 index 0000000000..9070e384f0 --- /dev/null +++ b/docs/config.yaml @@ -0,0 +1,15 @@ +# THIS IS A TEST CONFIG ONLY! +# FOR THE CONFIGURATION OF YOUR SITE USE hugo.yaml. +# +# As of Docsy 0.7.0, Hugo 0.110.0 or later must be used. +# +# The sole purpose of this config file is to detect Hugo-module builds that use +# an older version of Hugo. +# +# DO NOT add any config parameters to this file. You can safely delete this file +# if your project is using the required Hugo version. + +module: + hugoVersion: + extended: true + min: 0.110.0 diff --git a/docs/content/en/_index.md b/docs/content/en/_index.md new file mode 100644 index 0000000000..f375ebfb97 --- /dev/null +++ b/docs/content/en/_index.md @@ -0,0 +1,69 @@ +--- +title: Java Operator SDK Documentation +--- + +{{< blocks/cover title="Java Operator SDK" image_anchor="top" height="full" >}} + + Learn More + + + Download + +

Kubernetes operators in Java made easy!

+{{< blocks/link-down color="info" >}} +{{< /blocks/cover >}} + + +{{% blocks/lead color="gray" %}} +Whether you want to build applications that operate themselves or provision infrastructure from Java code, Kubernetes Operators are the way to go. +Java Operator SDK is based on the fabric8 Kubernetes client and will make it easy for Java developers to embrace this new way of automation. +{{% /blocks/lead %}} + + +{{% blocks/section color="secondary" type="row" %}} +{{% blocks/feature icon="fab fa-slack" title="Contact us on Slack" url="https://kubernetes.slack.com/archives/CAW0GV7A5" %}} +Feel free to reach out on [Kubernetes Slack](https://kubernetes.slack.com/archives/CAW0GV7A5) + +Ask any question, we are happy to answer! +{{% /blocks/feature %}} + + +{{% blocks/feature icon="fab fa-github" title="Contributions welcome!" url="https://github.com/operator-framework/java-operator-sdk" %}} +We do a [Pull Request](https://github.com/operator-framework/java-operator-sdk/pulls) contributions workflow on **GitHub**. New users are always welcome! +{{% /blocks/feature %}} + + +{{% blocks/feature icon="fa-brands fa-bluesky" title="Follow us on BlueSky!" url="https://bsky.app/profile/javaoperatorsdk.bsky.social" %}} +For announcement of latest features etc. +{{% /blocks/feature %}} + + +{{% /blocks/section %}} + + +{{% blocks/section %}} + +Sponsored by: +{.h1 .text-center} + +
Red Hat   &   Container Solutions +{.h1 .text-center} + +{{% /blocks/section %}} + + +{{% blocks/section type="row" %}} + +{{% blocks/feature icon="no_icon" %}} +{{% /blocks/feature %}} + +{{% blocks/feature icon="no_icon" %}} +Java Operator SDK is a [Cloud Native Computing Foundation](https://www.cncf.io) incubating project as part of [Operator Framework](https://www.cncf.io/projects/operator-framework/) +{.h3 .text-center} + +CNCF + +{{% /blocks/feature %}} + +{{% /blocks/section %}} + diff --git a/docs/content/en/blog/_index.md b/docs/content/en/blog/_index.md new file mode 100644 index 0000000000..e792e415fe --- /dev/null +++ b/docs/content/en/blog/_index.md @@ -0,0 +1,8 @@ +--- +title: Blog +menu: {main: {weight: 2}} +--- + +This is the **blog** section. It has two categories: News and Releases. + +Content is coming soon. diff --git a/docs/content/en/blog/news/_index.md b/docs/content/en/blog/news/_index.md new file mode 100644 index 0000000000..aaf1c2adcd --- /dev/null +++ b/docs/content/en/blog/news/_index.md @@ -0,0 +1,4 @@ +--- +title: Posts +weight: 220 +--- diff --git a/docs/content/en/blog/news/etcd-as-app-db.md b/docs/content/en/blog/news/etcd-as-app-db.md new file mode 100644 index 0000000000..c6306ddffc --- /dev/null +++ b/docs/content/en/blog/news/etcd-as-app-db.md @@ -0,0 +1,115 @@ +--- +title: Using k8s' ETCD as your application DB +date: 2025-01-16 +--- + +# FAQ: Is Kubernetes’ ETCD the Right Database for My Application? + +## Answer + +While the idea of moving your application data to Custom Resources (CRs) aligns with the "Cloud Native" philosophy, it often introduces more challenges than benefits. Let’s break it down: + +--- + +### Top Reasons Why Storing Data in ETCD Through CRs Looks Appealing + +1. **Storing application data as CRs enables treating your application’s data like infrastructure:** + - **GitOps compatibility:** Declarative content can be stored in Git repositories, ensuring reproducibility. + - **Infrastructure alignment:** Application data can follow the same workflow as other infrastructure components. + +--- + +### Challenges of Using Kubernetes’ ETCD as Your Application’s Database + +#### Technical Limitations: + +- **Data Size Limitations 🔴:** + - Each CR is capped at 1.5 MB by default. Raising this limit is possible but impacts cluster performance. + - Kubernetes ETCD has a storage cap of 2 GB by default. Adjusting this limit affects the cluster globally, with potential performance degradation. + +- **API Server Load Considerations 🟡:** + - The Kubernetes API server is designed to handle infrastructure-level requests. + - Storing application data in CRs might add significant load to the API server, requiring it to be scaled appropriately to handle both infrastructure and application demands. + - This added load can impact cluster performance and increase operational complexity. + +- **Guarantees 🟡:** + - Efficient queries are hard to implement and there is no support for them. + - ACID properties are challenging to leverage and everything holds mostly in read-only mode. + +#### Operational Impact: + +- **Lost Flexibility 🟡:** + - Modifying application data requires complex YAML editing and full redeployment. + - This contrasts with traditional databases that often feature user-friendly web UIs or APIs for real-time updates. + +- **Infrastructure Complexity 🟠:** + - Backup, restore, and lifecycle management for application data are typically separate from deployment workflows. + - Storing both in ETCD mixes these concerns, complicating operations and standardization. + +#### Security: + +- **Governance and Security 🔴:** + - Sensitive data stored in plain YAML may lack adequate encryption or access controls. + - Applying governance policies over text-based files can become a significant challenge. + +--- + +### When Might Using CRs Make Sense? + +For small, safe subsets of data—such as application configurations—using CRs might be appropriate. However, this approach requires a detailed evaluation of the trade-offs. + +--- + +### Conclusion + +While it’s tempting to unify application data with infrastructure control via CRs, this introduces risks that can outweigh the benefits. For most applications, separating concerns by using a dedicated database is the more robust, scalable, and manageable solution. + +--- + +### A Practical Example + +A typical “user” described in JSON: + +```json +{ + "username": "myname", + "enabled": true, + "email": "myname@test.com", + "firstName": "MyFirstName", + "lastName": "MyLastName", + "credentials": [ + { + "type": "password", + "value": "test" + }, + { + "type": "token", + "value": "oidc" + } + ], + "realmRoles": [ + "user", + "viewer", + "admin" + ], + "clientRoles": { + "account": [ + "view-profile", + "change-group", + "manage-account" + ] + } +} +``` + +This example represents about **0.5 KB of data**, meaning (with standard settings) a maximum of ~2000 users can be defined in the same CR. +Additionally: + +- It contains **sensitive information**, which should be securely stored. +- Regulatory rules (like GDPR) apply. + +--- + +### References + +- [Using etcd as primary store database](https://stackoverflow.com/questions/41063238/using-etcd-as-primary-store-database) diff --git a/docs/content/en/blog/news/nonssa-vs-ssa.md b/docs/content/en/blog/news/nonssa-vs-ssa.md new file mode 100644 index 0000000000..8ea7497771 --- /dev/null +++ b/docs/content/en/blog/news/nonssa-vs-ssa.md @@ -0,0 +1,117 @@ +--- +title: From legacy approach to server-side apply +date: 2025-02-25 +author: >- + [Attila Mészáros](https://github.com/csviri) +--- + +From version 5 of Java Operator SDK [server side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) +is a first-class feature and is used by default to update resources. +As we will see, unfortunately (or fortunately), using it requires changes for your reconciler implementation. + +For this reason, we prepared a feature flag, which you can flip if you are not prepared to migrate yet: +[`ConfigurationService.useSSAToPatchPrimaryResource`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L493) + +Setting this flag to false will make the operations done by `UpdateControl` using the former approach (not SSA). +Similarly, the finalizer handling won't utilize SSA handling. +The plan is to keep this flag and allow the use of the former approach (non-SSA) also in future releases. + +For dependent resources, a separate flag exists (this was true also before v5) to use SSA or not: +[`ConfigurationService.ssaBasedCreateUpdateMatchForDependentResources`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L373) + + +## Resource handling without and with SSA + +Until version 5, changing primary resources through `UpdateControl` did not use server-side apply. +So usually, the implementation of the reconciler looked something like this: + +```java + + @Override + public UpdateControl reconcile(WebPage webPage, Context context) { + + reconcileLogicForManagedResources(webPage); + webPage.setStatus(updatedStatusForWebPage(webPage)); + + return UpdateControl.patchStatus(webPage); + } + +``` + +In other words, after the reconciliation of managed resources, the reconciler updates the status of the +primary resource passed as an argument to the reconciler. +Such changes on the primary are fine since we don't work directly with the cached object, the argument is +already cloned. + +So, how does this change with SSA? +For SSA, the updates should contain (only) the "fully specified intent". +In other words, we should only fill in the values we care about. +In practice, it means creating a **fresh copy** of the resource and setting only what is necessary: + +```java + +@Override +public UpdateControl reconcile(WebPage webPage, Context context) { + + reconcileLogicForManagedResources(webPage); + + WebPage statusPatch = new WebPage(); + statusPatch.setMetadata(new ObjectMetaBuilder() + .withName(webPage.getMetadata().getName()) + .withNamespace(webPage.getMetadata().getNamespace()) + .build()); + statusPatch.setStatus(updatedStatusForWebPage(webPage)); + + return UpdateControl.patchStatus(statusPatch); +} +``` + +Note that we just filled out the status here since we patched the status (not the resource spec). +Since the status is a sub-resource in Kubernetes, it will only update the status part. + +Every controller you register will have its default [field manager](https://kubernetes.io/docs/reference/using-api/server-side-apply/#managers). +You can override the field manager name using [`ControllerConfiguration.fieldManager`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java#L89). +That will set the field manager for the primary resource and dependent resources as well. + +## Migrating to SSA + +Using the legacy or the new SSA way of resource management works well. +However, migrating existing resources to SSA might be a challenge. +We strongly recommend testing the migration, thus implementing an integration test where +a custom resource is created using the legacy approach and is managed by the new approach. + +We prepared an integration test to demonstrate how such migration, even in a simple case, can go wrong, +and how to fix it. + +To fix some cases, you might need to [strip managed fields](https://kubernetes.io/docs/reference/using-api/server-side-apply/#clearing-managedfields) +from the custom resource. + +See [`StatusPatchSSAMigrationIT`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchSSAMigrationIT.java) for details. + +Feel free to report common issues, so we can prepare some utilities to handle them. + +## Optimistic concurrency control + +When you create a resource for SSA as mentioned above, the framework will apply changes even if the underlying resource +or status subresource is changed while the reconciliation was running. +First, it always forces the conflicts in the background as advised in [Kubernetes docs](https://kubernetes.io/docs/reference/using-api/server-side-apply/#using-server-side-apply-in-a-controller), + in addition to that since the resource version is not set it won't do optimistic locking. If you still +want to have optimistic locking for the patch, use the resource version of the original resource: + +```java +@Override +public UpdateControl reconcile(WebPage webPage, Context context) { + + reconcileLogicForManagedResources(webPage); + + WebPage statusPatch = new WebPage(); + statusPatch.setMetadata(new ObjectMetaBuilder() + .withName(webPage.getMetadata().getName()) + .withNamespace(webPage.getMetadata().getNamespace()) + .withResourceVersion(webPage.getMetadata().getResourceVersion()) + .build()); + statusPatch.setStatus(updatedStatusForWebPage(webPage)); + + return UpdateControl.patchStatus(statusPatch); +} +``` diff --git a/docs/content/en/blog/news/primary-cache-for-next-recon.md b/docs/content/en/blog/news/primary-cache-for-next-recon.md new file mode 100644 index 0000000000..67326a6f17 --- /dev/null +++ b/docs/content/en/blog/news/primary-cache-for-next-recon.md @@ -0,0 +1,92 @@ +--- +title: How to guarantee allocated values for next reconciliation +date: 2025-05-22 +author: >- + [Attila Mészáros](https://github.com/csviri) and [Chris Laprun](https://github.com/metacosm) +--- + +We recently released v5.1 of Java Operator SDK (JOSDK). One of the highlights of this release is related to a topic of +so-called +[allocated values](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#representing-allocated-values +). + +To describe the problem, let's say that our controller needs to create a resource that has a generated identifier, i.e. +a resource which identifier cannot be directly derived from the custom resource's desired state as specified in its +`spec` field. To record the fact that the resource was successfully created, and to avoid attempting to +recreate the resource again in subsequent reconciliations, it is typical for this type of controller to store the +generated identifier in the custom resource's `status` field. + +The Java Operator SDK relies on the informers' cache to retrieve resources. These caches, however, are only guaranteed +to be eventually consistent. It could happen that, if some other event occurs, that would result in a new +reconciliation, **before** the update that's been made to our resource status has the chance to be propagated first to +the cluster and then back to the informer cache, that the resource in the informer cache does **not** contain the latest +version as modified by the reconciler. This would result in a new reconciliation where the generated identifier would be +missing from the resource status and, therefore, another attempt to create the resource by the reconciler, which is not +what we'd like. + +Java Operator SDK now provides a utility class [ +`PrimaryUpdateAndCacheUtils`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java) +to handle this particular use case. Using that overlay cache, your reconciler is guaranteed to see the most up-to-date +version of the resource on the next reconciliation: + +```java + +@Override +public UpdateControl reconcile( + StatusPatchCacheCustomResource resource, + Context context) { + + // omitted code + + var freshCopy = createFreshCopy(resource); // need fresh copy just because we use the SSA version of update + freshCopy + .getStatus() + .setValue(statusWithAllocatedValue()); + + // using the utility instead of update control to patch the resource status + var updated = + PrimaryUpdateAndCacheUtils.ssaPatchStatusAndCacheResource(resource, freshCopy, context); + return UpdateControl.noUpdate(); +} +``` + +How does `PrimaryUpdateAndCacheUtils` work? +There are multiple ways to solve this problem, but ultimately, we only provide the solution described below. If you +want to dig deep in alternatives, see +this [PR](https://github.com/operator-framework/java-operator-sdk/pull/2800/files). + +The trick is to intercept the resource that the reconciler updated and cache that version in an additional cache on top +of the informer's cache. Subsequently, if the reconciler needs to read the resource, the SDK will first check if it is +in the overlay cache and read it from there if present, otherwise read it from the informer's cache. If the informer +receives an event with a fresh resource, we always remove the resource from the overlay cache, since that is a more +recent resource. But this **works only** if the reconciler updates the resource using **optimistic locking**. +If the update fails on conflict, because the resource has already been updated on the cluster before we got +the chance to get our update in, we simply wait and poll the informer cache until the new resource version from the +server appears in the informer's cache, +and then try to apply our updates to the resource again using the updated version from the server, again with optimistic +locking. + +So why is optimistic locking required? We hinted at it above, but the gist of it, is that if another party updates the +resource before we get a chance to, we wouldn't be able to properly handle the resulting situation correctly in all +cases. The informer would receive that new event before our own update would get a chance to propagate. Without +optimistic locking, there wouldn't be a fail-proof way to determine which update should prevail (i.e. which occurred +first), in particular in the event of the informer losing the connection to the cluster or other edge cases (the joys of +distributed computing!). + +Optimistic locking simplifies the situation and provides us with stronger guarantees: if the update succeeds, then we +can be sure we have the proper resource version in our caches. The next event will contain our update in all cases. +Because we know that, we can also be sure that we can evict the cached resource in the overlay cache whenever we receive +a new event. The overlay cache is only used if the SDK detects that the original resource (i.e. the one before we +applied our status update in the example above) is still in the informer's cache. + +The following diagram sums up the process: + +```mermaid +flowchart TD + A["Update Resource with Lock"] --> B{"Is Successful"} + B -- Fails on conflict --> D["Poll the Informer cache until resource updated"] + D --> A + B -- Yes --> n2{"Original resource still in informer cache?"} + n2 -- Yes --> C["Cache the resource in overlay cache"] + n2 -- No --> n3["Informer cache already contains up-to-date version, do not use overlay cache"] +``` diff --git a/docs/content/en/blog/releases/_index.md b/docs/content/en/blog/releases/_index.md new file mode 100644 index 0000000000..dbf2ee1729 --- /dev/null +++ b/docs/content/en/blog/releases/_index.md @@ -0,0 +1,4 @@ +--- +title: Releases +weight: 230 +--- diff --git a/docs/content/en/blog/releases/v5-release-beta1.md b/docs/content/en/blog/releases/v5-release-beta1.md new file mode 100644 index 0000000000..7dd133cc1d --- /dev/null +++ b/docs/content/en/blog/releases/v5-release-beta1.md @@ -0,0 +1,6 @@ +--- +title: Version 5 Released! (beta1) +date: 2024-12-06 +--- + +See release notes [here](v5-release.md). \ No newline at end of file diff --git a/docs/content/en/blog/releases/v5-release.md b/docs/content/en/blog/releases/v5-release.md new file mode 100644 index 0000000000..6d14dfb73a --- /dev/null +++ b/docs/content/en/blog/releases/v5-release.md @@ -0,0 +1,397 @@ +--- +title: Version 5 Released! +date: 2025-01-06 +--- + +We are excited to announce that Java Operator SDK v5 has been released. This significant effort contains +various features and enhancements accumulated since the last major release and required changes in our APIs. +Within this post, we will go through all the main changes and help you upgrade to this new version, and provide +a rationale behind the changes if necessary. + +We will omit descriptions of changes that should only require simple code updates; please do contact +us if you encounter issues anyway. + +You can see an introduction and some important changes and rationale behind them from [KubeCon](https://youtu.be/V0NYHt2yjcM?t=1238). + +## Various Changes + +- From this release, the minimal Java version is 17. +- Various deprecated APIs are removed. Migration should be easy. + +## All Changes + +You can see all changes [here](https://github.com/operator-framework/java-operator-sdk/compare/v4.9.7...v5.0.0). + +## Changes in low-level APIs + +### Server Side Apply (SSA) + +[Server Side Apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) is now a first-class citizen in +the framework and +the default approach for patching the status resource. This means that patching a resource or its status through +`UpdateControl` and adding +the finalizer in the background will both use SSA. + +Migration from a non-SSA based patching to an SSA based one can be problematic. Make sure you test the transition when +you migrate from older version of the frameworks. +To continue to use a non-SSA based on, +set [ConfigurationService.useSSAToPatchPrimaryResource](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L462) +to `false`. + +See some identified problematic migration cases and how to handle them +in [StatusPatchSSAMigrationIT](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchSSAMigrationIT.java). + +For more detailed description, see our [blog post](../news/nonssa-vs-ssa.md) on SSA. + +### Event Sources related changes + +#### Multi-cluster support in InformerEventSource + +`InformerEventSource` now supports watching remote clusters. You can simply pass a `KubernetesClient` instance +initialized to connect to a different cluster from the one where the controller runs when configuring your event source. +See [InformerEventSourceConfiguration.withKubernetesClient](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java) + +Such an informer behaves exactly as a regular one. Owner references won't work in this situation, though, so you have to +specify a `SecondaryToPrimaryMapper` (probably based on labels or annotations). + +See related integration +test [here](https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster) + +#### SecondaryToPrimaryMapper now checks resource types + +The owner reference based mappers are now checking the type (`kind` and `apiVersion`) of the resource when resolving the +mapping. This is important +since a resource may have owner references to a different resource type with the same name. + +See implementation +details [here](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/Mappers.java#L74-L75) + +#### InformerEventSource-related changes + +There are multiple smaller changes to `InformerEventSource` and related classes: + +1. `InformerConfiguration` is renamed + to [ + `InformerEventSourceConfiguration`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java) +2. `InformerEventSourceConfiguration` doesn't require `EventSourceContext` to be initialized anymore. + +#### All EventSource are now ResourceEventSources + +The [ +`EventSource`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java) +abstraction is now always aware of the resources and +handles accessing (the cached) resources, filtering, and additional capabilities. Before v5, such capabilities were +present only in a sub-class called `ResourceEventSource`, +but we decided to merge and remove `ResourceEventSource` since this has a nice impact on other parts of the system in +terms of architecture. + +If you still need to create an `EventSource` that only supports triggering of your reconciler, +see [ +`TimerEventSource`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java) +for an example of how this can be accomplished. + +#### Naming event sources + +[ +`EventSource`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java#L45) +are now named. This reduces the ambiguity that might have existed when trying to refer to an `EventSource`. + +### ControllerConfiguration annotation related changes + +You no longer have to annotate the reconciler with `@ControllerConfiguration` annotation. +This annotation is (one) way to override the default properties of a controller. +If the annotation is not present, the default values from the annotation are used. + +PR: https://github.com/operator-framework/java-operator-sdk/pull/2203 + +In addition to that, the informer-related configurations are now extracted into +a separate [ +`@Informer`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java) +annotation within [ +`@ControllerConfiguration`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java#L24). +Hopefully this explicits which part of the configuration affects the informer associated with primary resource. +Similarly, the same `@Informer` annotation is used when configuring the informer associated with a managed +`KubernetesDependentResource` via the +[ +`KubernetesDependent`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java#L33) +annotation. + +### EventSourceInitializer and ErrorStatusHandler are removed + +Both the `EventSourceInitializer` and `ErrorStatusHandler` interfaces are removed, and their methods moved directly +under [ +`Reconciler`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Reconciler.java#L30-L56). + +If possible, we try to avoid such marker interfaces since it is hard to deduce related usage just by looking at the +source code. +You can now simply override those methods when implementing the `Reconciler` interface. + +### Cloning accessing secondary resources + +When accessing the secondary resources using [ +`Context.getSecondaryResource(s)(...)`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java#L19-L29), +the resources are no longer cloned by default, since +cloning could have an impact on performance. This means that you now need to ensure that these any changes +are now made directly to the underlying cached resource. This should be avoided since the same resource instance may be +present for other reconciliation cycles and would +no longer represent the state on the server. + +If you want to still clone resources by default, +set [ +`ConfigurationService.cloneSecondaryResourcesWhenGettingFromCache`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L484) +to `true`. + +### Removed automated observed generation handling + +The automatic observed generation handling feature was removed since it is easy to implement inside the reconciler, but +it made +the implementation much more complex, especially if the framework would have to support it both for served side apply +and client side apply. + +You can check a sample implementation how to do it manually in +this [integration test](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/). + +## Dependent Resource related changes + +### ResourceDiscriminator is removed and related changes + +The primary reason `ResourceDiscriminator` was introduced was to cover the case when there are +more than one dependent resources of a given type associated with a given primary resource. In this situation, JOSDK +needed a generic mechanism to +identify which resources on the cluster should be associated with which dependent resource implementation. +We improved this association mechanism, thus rendering `ResourceDiscriminator` obsolete. + +As a replacement, the dependent resource will select the target resource based on the desired state. +See the generic implementation in [ +`AbstractDependentResource`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java#L135-L144). +Calculating the desired state can be costly and might depend on other resources. For `KubernetesDependentResource` +it is usually enough to provide the name and namespace (if namespace-scoped) of the target resource, which is what the +`KubernetesDependentResource` implementation does by default. If you can determine which secondary to target without +computing the desired state via its associated `ResourceID`, then we encourage you to override the +[ +`ResourceID targetSecondaryResourceID()`](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java#L234-L244) +method as shown +in [this example](https://github.com/operator-framework/java-operator-sdk/blob/c7901303c5304e6017d050f05cbb3d4930bdfe44/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorConfigMap1.java#L24-L35) + +### Read-only bulk dependent resources + +Read-only bulk dependent resources are now supported; this was a request from multiple users, but it required changes to +the underlying APIs. +Please check the documentation for further details. + +See also the +related [integration test](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly). + +### Multiple Dependents with Activation Condition + +Until now, activation conditions had a limitation that only one condition was allowed for a specific resource type. +For example, two `ConfigMap` dependent resources were not allowed, both with activation conditions. The underlying issue +was with the informer registration process. When an activation condition is evaluated as "met" in the background, +the informer is registered dynamically for the target resource type. However, we need to avoid registering multiple +informers of the same kind. To prevent this the dependent resource must specify +the [name of the informer](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/ConfigMapDependentResource2.java#L12). + +See the complete +example [here](https://github.com/operator-framework/java-operator-sdk/blob/1635c9ea338f8e89bacc547808d2b409de8734cf/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation). + +### `getSecondaryResource` is Activation condition aware + +When an activation condition for a resource type is not met, no associated informer might be registered for that +resource type. However, in this situation, calling `Context.getSecondaryResource` +and its alternatives would previously throw an exception. This was, however, rather confusing and a better user +experience would be to return an empty value instead of throwing an error. We changed this behavior in v5 to make it +more user-friendly and attempting to retrieve a secondary resource that is gated by an activation condition will now +return an empty value as if the associated informer existed. + +See related [issue](https://github.com/operator-framework/java-operator-sdk/issues/2198) for details. + +## Workflow related changes + +### `@Workflow` annotation + +The managed workflow definition is now a separate `@Workflow` annotation; it is no longer part of +`@ControllerConfiguration`. + +See sample +usage [here](https://github.com/operator-framework/java-operator-sdk/blob/664cb7109fe62f9822997d578ae7f57f17ef8c26/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageManagedDependentsReconciler.java#L14-L20) + +### Explicit workflow invocation + +Before v5, the managed dependents part of a workflow would always be reconciled before the primary `Reconciler` +`reconcile` or `cleanup` methods were called. It is now possible to explictly ask for a workflow reconciliation in your +primary `Reconciler`, thus allowing you to control when the workflow is reconciled. This mean you can perform all kind +of operations - typically validations - before executing the workflow, as shown in the sample below: + +```java + +@Workflow(explicitInvocation = true, + dependents = @Dependent(type = ConfigMapDependent.class)) +@ControllerConfiguration +public class WorkflowExplicitCleanupReconciler + implements Reconciler, + Cleaner { + + @Override + public UpdateControl reconcile( + WorkflowExplicitCleanupCustomResource resource, + Context context) { + + context.managedWorkflowAndDependentResourceContext().reconcileManagedWorkflow(); + + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup(WorkflowExplicitCleanupCustomResource resource, + Context context) { + + context.managedWorkflowAndDependentResourceContext().cleanupManageWorkflow(); + // this can be checked + // context.managedWorkflowAndDependentResourceContext().getWorkflowCleanupResult() + return DeleteControl.defaultDelete(); + } +} +``` + +To turn on this mode of execution, set [ +`explicitInvocation`](https://github.com/operator-framework/java-operator-sdk/blob/664cb7109fe62f9822997d578ae7f57f17ef8c26/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Workflow.java#L26) +flag to `true` in the managed workflow definition. + +See the following integration tests +for [ +`invocation`](https://github.com/operator-framework/java-operator-sdk/blob/664cb7109fe62f9822997d578ae7f57f17ef8c26/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation) +and [ +`cleanup`](https://github.com/operator-framework/java-operator-sdk/blob/664cb7109fe62f9822997d578ae7f57f17ef8c26/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup). + +### Explicit exception handling + +If an exception happens during a workflow reconciliation, the framework automatically throws it further. +You can now set [ +`handleExceptionsInReconciler`](https://github.com/operator-framework/java-operator-sdk/blob/664cb7109fe62f9822997d578ae7f57f17ef8c26/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Workflow.java#L40) +to true for a workflow and check the thrown exceptions explicitly +in the execution results. + +```java + +@Workflow(handleExceptionsInReconciler = true, + dependents = @Dependent(type = ConfigMapDependent.class)) +@ControllerConfiguration +public class HandleWorkflowExceptionsInReconcilerReconciler + implements Reconciler, + Cleaner { + + private volatile boolean errorsFoundInReconcilerResult = false; + private volatile boolean errorsFoundInCleanupResult = false; + + @Override + public UpdateControl reconcile( + HandleWorkflowExceptionsInReconcilerCustomResource resource, + Context context) { + + errorsFoundInReconcilerResult = context.managedWorkflowAndDependentResourceContext() + .getWorkflowReconcileResult().erroredDependentsExist(); + + // check errors here: + Map errors = context.getErroredDependents(); + + return UpdateControl.noUpdate(); + } +} +``` + +See integration +test [here](https://github.com/operator-framework/java-operator-sdk/blob/664cb7109fe62f9822997d578ae7f57f17ef8c26/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling). + +### CRDPresentActivationCondition + +Activation conditions are typically used to check if the cluster has specific capabilities (e.g., is cert-manager +available). +Such a check can be done by verifying if a particular custom resource definition (CRD) is present on the cluster. You +can now use the generic [ +`CRDPresentActivationCondition`](https://github.com/operator-framework/java-operator-sdk/blob/664cb7109fe62f9822997d578ae7f57f17ef8c26/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationCondition.java) +for this +purpose, it will check if the CRD of a target resource type of a dependent resource exists on the cluster. + +See usage in integration +test [here](https://github.com/operator-framework/java-operator-sdk/blob/refs/heads/next/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation). + +## Fabric8 client updated to 7.0 + +The Fabric8 client has been updated to version 7.0.0. This is a new major version which implies that some API might have +changed. Please take a look at the [Fabric8 client 7.0.0 migration guide](https://github.com/fabric8io/kubernetes-client/blob/main/doc/MIGRATION-v7.md). + +### CRD generator changes + +Starting with v5.0 (in accordance with changes made to the Fabric8 client in version 7.0.0), the CRD generator will use the maven plugin instead of the annotation processor as was previously the case. +In many instances, you can simply configure the plugin by adding the following stanza to your project's POM build configuration: + +```xml + + io.fabric8 + crd-generator-maven-plugin + ${fabric8-client.version} + + + + generate + + + + + +``` +*NOTE*: If you use the SDK's JUnit extension for your tests, you might also need to configure the CRD generator plugin to access your test `CustomResource` implementations as follows: +```xml + + + io.fabric8 + crd-generator-maven-plugin + ${fabric8-client.version} + + + + generate + + process-test-classes + + ${project.build.testOutputDirectory} + WITH_ALL_DEPENDENCIES_AND_TESTS + + + + + +``` + +Please refer to the [CRD generator documentation](https://github.com/fabric8io/kubernetes-client/blob/main/doc/CRD-generator.md) for more details. + + +## Experimental + +### Check if the following reconciliation is imminent + +You can now check if the subsequent reconciliation will happen right after the current one because the SDK has already +received an event that will trigger a new reconciliation +This information is available from +the [ +`Context`](https://github.com/operator-framework/java-operator-sdk/blob/664cb7109fe62f9822997d578ae7f57f17ef8c26/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java#L69). + +Note that this could be useful, for example, in situations when a heavy task would be repeated in the follow-up +reconciliation. In the current +reconciliation, you can check this flag and return to avoid unneeded processing. Note that this is a semi-experimental +feature, so please let us know +if you found this helpful. + +```java + +@Override +public UpdateControl reconcile(MyCustomResource resource, Context context) { + + if (context.isNextReconciliationImminent()) { + // your logic, maybe return? + } +} +``` + +See +related [integration test](https://github.com/operator-framework/java-operator-sdk/blob/664cb7109fe62f9822997d578ae7f57f17ef8c26/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent). \ No newline at end of file diff --git a/docs/content/en/community/_index.md b/docs/content/en/community/_index.md new file mode 100644 index 0000000000..fa42c2d974 --- /dev/null +++ b/docs/content/en/community/_index.md @@ -0,0 +1,6 @@ +--- +title: Community +menu: {main: {weight: 3}} +--- + + diff --git a/docs/content/en/docs/_index.md b/docs/content/en/docs/_index.md new file mode 100755 index 0000000000..5c7b74ab4b --- /dev/null +++ b/docs/content/en/docs/_index.md @@ -0,0 +1,6 @@ +--- +title: Documentation +linkTitle: Docs +menu: {main: {weight: 1}} +weight: 1 +--- diff --git a/docs/content/en/docs/contributing/_index.md b/docs/content/en/docs/contributing/_index.md new file mode 100644 index 0000000000..0ab40d55b1 --- /dev/null +++ b/docs/content/en/docs/contributing/_index.md @@ -0,0 +1,68 @@ +--- +title: Contributing +weight: 110 +--- + +Thank you for considering contributing to the Java Operator SDK project! We're building a vibrant community and need help from people like you to make it happen. + +## Code of Conduct + +We're committed to making this a welcoming, inclusive project. We do not tolerate discrimination, aggressive or insulting behavior. + +This project and all participants are bound by our [Code of Conduct]({{baseurl}}/coc). By participating, you're expected to uphold this code. Please report unacceptable behavior to any project admin. + +## Reporting Bugs + +Found a bug? Please [open an issue](https://github.com/java-operator-sdk/java-operator-sdk/issues)! Include all details needed to recreate the problem: + +- Operator SDK version being used +- Exact platform and version you're running on +- Steps to reproduce the bug +- Reproducer code (very helpful for quick diagnosis and fixes) + +## Contributing Features and Documentation + +Looking for something to work on? Check the issue tracker, especially items labeled [good first issue](https://github.com/java-operator-sdk/java-operator-sdk/labels/good%20first%20issue). Please comment on the issue when you start work to avoid duplicated effort. + +### Feature Ideas + +Have a feature idea? Open an issue labeled "enhancement" even if you can't work on it immediately. We'll discuss it as a community and see what's possible. + +**Important**: Some features may not align with project goals. Please discuss new features before starting work to avoid wasted effort. We commit to listening to all proposals and working something out when possible. + +### Development Process + +Once you have approval to work on a feature: +1. Communicate progress via issue updates or our [Discord channel](https://discord.gg/DacEhAy) +2. Ask for feedback and pointers as needed +3. Open a Pull Request when ready + +## Pull Request Process + +### Commit Messages +Format commit messages following [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) format. + +### Testing and Review +- GitHub Actions will run the test suite on your PR +- All code must pass tests +- New code must include new tests +- All PRs require review and sign-off from another developer +- Expect requests for changes - this is normal and part of the process +- PRs must comply with Java Google code style + +### Licensing +All Operator SDK code is released under the [Apache 2.0 licence](LICENSE). + +## Development Environment Setup + +### Code Style + +SDK modules and samples follow Java Google code style. Code gets formatted automatically on every `compile`, but to avoid PR rejections due to style issues, set up your IDE: + +**IntelliJ IDEA**: Install the [google-java-format](https://plugins.jetbrains.com/plugin/8527-google-java-format) plugin + +**Eclipse**: Follow [these instructions](https://github.com/google/google-java-format?tab=readme-ov-file#eclipse) + +## Acknowledgments + +These guidelines were inspired by [Atom](https://github.com/atom/atom/blob/master/CONTRIBUTING.md), [PurpleBooth's advice](https://gist.github.com/PurpleBooth/b24679402957c63ec426), and the [Contributor Covenant](https://www.contributor-covenant.org/). diff --git a/docs/content/en/docs/documentation/_index.md b/docs/content/en/docs/documentation/_index.md new file mode 100644 index 0000000000..59373c6974 --- /dev/null +++ b/docs/content/en/docs/documentation/_index.md @@ -0,0 +1,25 @@ +--- +title: Documentation +weight: 40 +--- + +# JOSDK Documentation + +This section contains detailed documentation for all Java Operator SDK features and concepts. Whether you're building your first operator or need advanced configuration options, you'll find comprehensive guides here. + +## Core Concepts + +- **[Implementing a Reconciler](reconciler/)** - The heart of any operator +- **[Architecture](architecture/)** - How JOSDK works under the hood +- **[Dependent Resources & Workflows](dependent-resource-and-workflows/)** - Managing resource relationships +- **[Configuration](configuration/)** - Customizing operator behavior +- **[Error Handling & Retries](error-handling-retries/)** - Managing failures gracefully + +## Advanced Features + +- **[Eventing](eventing/)** - Understanding the event-driven model +- **[Accessing Resources in Caches](working-with-es-caches/) - How to access resources in caches +- **[Observability](observability/)** - Monitoring and debugging your operators +- **[Other Features](features/)** - Additional capabilities and integrations + +Each guide includes practical examples and best practices to help you build robust, production-ready operators. diff --git a/docs/content/en/docs/documentation/architecture.md b/docs/content/en/docs/documentation/architecture.md new file mode 100644 index 0000000000..4108849c04 --- /dev/null +++ b/docs/content/en/docs/documentation/architecture.md @@ -0,0 +1,36 @@ +--- +title: Architecture and Internals +weight: 85 +--- + +This document provides an overview of the Java Operator SDK's internal structure and components to help developers understand and contribute to the project. While not a comprehensive reference, it introduces core concepts that should make other components easier to understand. + +## The Big Picture and Core Components + +![JOSDK architecture](/images/architecture.svg) + +An [Operator](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java) is a set of independent [controllers](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java). + +The `Controller` class is an internal class managed by the framework and typically shouldn't be interacted with directly. It manages all processing units involved with reconciling a single type of Kubernetes resource. + +### Core Components + +- **[Reconciler](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Reconciler.java)** - The primary entry point for developers to implement reconciliation logic +- **[EventSource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java)** - Represents a source of events that might trigger reconciliation +- **[EventSourceManager](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java)** - Aggregates all event sources for a controller and manages their lifecycle +- **[ControllerResourceEventSource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSource.java)** - Central event source that watches primary resources associated with a given controller for changes, propagates events and caches state +- **[EventProcessor](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java)** - Processes incoming events sequentially per resource while allowing concurrent overall processing. Handles rescheduling and retrying +- **[ReconcilerDispatcher](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java)** - Dispatches requests to appropriate `Reconciler` methods and handles reconciliation results, making necessary Kubernetes API calls + +## Typical Workflow + +A typical workflow follows these steps: + +1. **Event Generation**: An `EventSource` produces an event and propagates it to the `EventProcessor` +2. **Resource Reading**: The resource associated with the event is read from the internal cache +3. **Reconciliation Submission**: If the resource isn't already being processed, a reconciliation request is submitted to the executor service in a different thread (encapsulated in a `ControllerExecution` instance) +4. **Dispatching**: The `ReconcilerDispatcher` is called, which dispatches the call to the appropriate `Reconciler` method with all required information +5. **Reconciler Execution**: Once the `Reconciler` completes, the `ReconcilerDispatcher` makes appropriate Kubernetes API server calls based on the returned result +6. **Finalization**: The `EventProcessor` is called back to finalize execution and update the controller's state +7. **Rescheduling Check**: The `EventProcessor` checks if the request needs rescheduling or retrying, and whether subsequent events were received for the same resource +8. **Completion**: When no further action is needed, event processing is finished diff --git a/docs/content/en/docs/documentation/configuration.md b/docs/content/en/docs/documentation/configuration.md new file mode 100644 index 0000000000..888804628f --- /dev/null +++ b/docs/content/en/docs/documentation/configuration.md @@ -0,0 +1,154 @@ +--- +title: Configurations +weight: 55 +--- + +The Java Operator SDK (JOSDK) provides abstractions that work great out of the box. However, we recognize that default behavior isn't always suitable for every use case. Numerous configuration options help you tailor the framework to your specific needs. + +Configuration options operate at several levels: +- **Operator-level** using `ConfigurationService` +- **Reconciler-level** using `ControllerConfiguration` +- **DependentResource-level** using the `DependentResourceConfigurator` interface +- **EventSource-level** where some event sources (like `InformerEventSource`) need fine-tuning to identify which events trigger the associated reconciler + +## Operator-Level Configuration + +Configuration that impacts the entire operator is performed via the `ConfigurationService` class. `ConfigurationService` is an abstract class with different implementations based on which framework flavor you use (e.g., Quarkus Operator SDK replaces the default implementation). Configurations initialize with sensible defaults but can be changed during initialization. + +For example, to disable CRD validation on startup and configure leader election: + +```java +Operator operator = new Operator( override -> override + .checkingCRDAndValidateLocalModel(false) + .withLeaderElectionConfiguration(new LeaderElectionConfiguration("bar", "barNS"))); +``` + +## Reconciler-Level Configuration + +While reconcilers are typically configured using the `@ControllerConfiguration` annotation, you can also override configuration at runtime when registering the reconciler with the operator. You can either: +- Pass a completely new `ControllerConfiguration` instance +- Override specific aspects using a `ControllerConfigurationOverrider` `Consumer` (preferred) + +```java +Operator operator; +Reconciler reconciler; +... +operator.register(reconciler, configOverrider -> + configOverrider.withFinalizer("my-nifty-operator/finalizer").withLabelSelector("foo=bar")); +``` + +## Dynamically Changing Target Namespaces + +A controller can be configured to watch a specific set of namespaces in addition of the +namespace in which it is currently deployed or the whole cluster. The framework supports +dynamically changing the list of these namespaces while the operator is running. +When a reconciler is registered, an instance of +[`RegisteredController`](https://github.com/java-operator-sdk/java-operator-sdk/blob/ec37025a15046d8f409c77616110024bf32c3416/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RegisteredController.java#L5) +is returned, providing access to the methods allowing users to change watched namespaces as the +operator is running. + +A typical scenario would probably involve extracting the list of target namespaces from a +`ConfigMap` or some other input but this part is out of the scope of the framework since this is +use-case specific. For example, reacting to changes to a `ConfigMap` would probably involve +registering an associated `Informer` and then calling the `changeNamespaces` method on +`RegisteredController`. + +```java + +public static void main(String[] args) { + KubernetesClient client = new DefaultKubernetesClient(); + Operator operator = new Operator(client); + RegisteredController registeredController = operator.register(new WebPageReconciler(client)); + operator.installShutdownHook(); + operator.start(); + + // call registeredController further while operator is running +} + +``` + +If watched namespaces change for a controller, it might be desirable to propagate these changes to +`InformerEventSources` associated with the controller. In order to express this, +`InformerEventSource` implementations interested in following such changes need to be +configured appropriately so that the `followControllerNamespaceChanges` method returns `true`: + +```java + +@ControllerConfiguration +public class MyReconciler implements Reconciler { + + @Override + public Map prepareEventSources( + EventSourceContext context) { + + InformerEventSource configMapES = + new InformerEventSource<>(InformerEventSourceConfiguration.from(ConfigMap.class, TestCustomResource.class) + .withNamespacesInheritedFromController(context) + .build(), context); + + return EventSourceUtils.nameEventSources(configMapES); + } + +} +``` + +As seen in the above code snippet, the informer will have the initial namespaces inherited from +controller, but also will adjust the target namespaces if it changes for the controller. + +See also +the [integration test](https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace) +for this feature. + +## DependentResource-level configuration + +It is possible to define custom annotations to configure custom `DependentResource` implementations. In order to provide +such a configuration mechanism for your own `DependentResource` implementations, they must be annotated with the +`@Configured` annotation. This annotation defines 3 fields that tie everything together: + +- `by`, which specifies which annotation class will be used to configure your dependents, +- `with`, which specifies the class holding the configuration object for your dependents and +- `converter`, which specifies the `ConfigurationConverter` implementation in charge of converting the annotation + specified by the `by` field into objects of the class specified by the `with` field. + +`ConfigurationConverter` instances implement a single `configFrom` method, which will receive, as expected, the +annotation instance annotating the dependent resource instance to be configured, but it can also extract information +from the `DependentResourceSpec` instance associated with the `DependentResource` class so that metadata from it can be +used in the configuration, as well as the parent `ControllerConfiguration`, if needed. The role of +`ConfigurationConverter` implementations is to extract the annotation information, augment it with metadata from the +`DependentResourceSpec` and the configuration from the parent controller on which the dependent is defined, to finally +create the configuration object that the `DependentResource` instances will use. + +However, one last element is required to finish the configuration process: the target `DependentResource` class must +implement the `ConfiguredDependentResource` interface, parameterized with the annotation class defined by the +`@Configured` annotation `by` field. This interface is called by the framework to inject the configuration at the +appropriate time and retrieve the configuration, if it's available. + +For example, `KubernetesDependentResource`, a core implementation that the framework provides, can be configured via the +`@KubernetesDependent` annotation. This set up is configured as follows: + +```java + +@Configured( + by = KubernetesDependent.class, + with = KubernetesDependentResourceConfig.class, + converter = KubernetesDependentConverter.class) +public abstract class KubernetesDependentResource + extends AbstractEventSourceHolderDependentResource> + implements ConfiguredDependentResource> { + // code omitted +} +``` + +The `@Configured` annotation specifies that `KubernetesDependentResource` instances can be configured by using the +`@KubernetesDependent` annotation, which gets converted into a `KubernetesDependentResourceConfig` object by a +`KubernetesDependentConverter`. That configuration object is then injected by the framework in the +`KubernetesDependentResource` instance, after it's been created, because the class implements the +`ConfiguredDependentResource` interface, properly parameterized. + +For more information on how to use this feature, we recommend looking at how this mechanism is implemented for +`KubernetesDependentResource` in the core framework, `SchemaDependentResource` in the samples or `CustomAnnotationDep` +in the `BaseConfigurationServiceTest` test class. + +## EventSource-level configuration + +TODO diff --git a/docs/content/en/docs/documentation/dependent-resource-and-workflows/_index.md b/docs/content/en/docs/documentation/dependent-resource-and-workflows/_index.md new file mode 100644 index 0000000000..9446f7ceca --- /dev/null +++ b/docs/content/en/docs/documentation/dependent-resource-and-workflows/_index.md @@ -0,0 +1,9 @@ +--- +title: Dependent resources and workflows +weight: 70 +--- + +Dependent resources and workflows are features sometimes referenced as higher +level abstractions. These two related concepts provides an abstraction +over reconciliation of a single resource (Dependent resource) and the +orchestration of such resources (Workflows). \ No newline at end of file diff --git a/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md b/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md new file mode 100644 index 0000000000..7416949869 --- /dev/null +++ b/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md @@ -0,0 +1,465 @@ +--- +title: Dependent resources +weight: 75 +--- + +## Motivations and Goals + +Most operators need to deal with secondary resources when trying to realize the desired state +described by the primary resource they are in charge of. For example, the Kubernetes-native +`Deployment` controller needs to manage `ReplicaSet` instances as part of a `Deployment`'s +reconciliation process. In this instance, `ReplicatSet` is considered a secondary resource for +the `Deployment` controller. + +Controllers that deal with secondary resources typically need to perform the following steps, for +each secondary resource: + +```mermaid +flowchart TD + +compute[Compute desired secondary resource based on primary state] --> A +A{Secondary resource exists?} +A -- Yes --> match +A -- No --> Create --> Done + +match{Matches desired state?} +match -- Yes --> Done +match -- No --> Update --> Done +``` + +While these steps are not difficult in and of themselves, there are some subtleties that can lead to +bugs or sub-optimal code if not done right. As this process is pretty much similar for each +dependent resource, it makes sense for the SDK to offer some level of support to remove the +boilerplate code associated with encoding these repetitive actions. It should +be possible to handle common cases (such as dealing with Kubernetes-native secondary resources) in a +semi-declarative way with only a minimal amount of code, JOSDK taking care of wiring everything +accordingly. + +Moreover, in order for your reconciler to get informed of events on these secondary resources, you +need to configure and create event sources and maintain them. JOSDK already makes it rather easy +to deal with these, but dependent resources makes it even simpler. + +Finally, there are also opportunities for the SDK to transparently add features that are even +trickier to get right, such as immediate caching of updated or created resources (so that your +reconciler doesn't need to wait for a cluster roundtrip to continue its work) and associated +event filtering (so that something your reconciler just changed doesn't re-trigger a +reconciliation, for example). + +## Design + +### `DependentResource` vs. `AbstractDependentResource` + +The new +[`DependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/DependentResource.java) +interface lies at the core of the design and strives to encapsulate the logic that is required +to reconcile the state of the associated secondary resource based on the state of the primary +one. For most cases, this logic will follow the flow expressed above and JOSDK provides a very +convenient implementation of this logic in the form of the +[`AbstractDependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java) +class. If your logic doesn't fit this pattern, though, you can still provide your +own `reconcile` method implementation. While the benefits of using dependent resources are less +obvious in that case, this allows you to separate the logic necessary to deal with each +secondary resource in its own class that can then be tested in isolation via unit tests. You can +also use the declarative support with your own implementations as we shall see later on. + +`AbstractDependentResource` is designed so that classes extending it specify which functionality +they support by implementing trait interfaces. This design has been selected to express the fact +that not all secondary resources are completely under the control of the primary reconciler: +some dependent resources are only ever created or updated for example and we needed a way to let +JOSDK know when that is the case. We therefore provide trait interfaces: `Creator`, +`Updater` and `Deleter` to express that the `DependentResource` implementation will provide custom +functionality to create, update and delete its associated secondary resources, respectively. If +these traits are not implemented then parts of the logic described above is never triggered: if +your implementation doesn't implement `Creator`, for example, `AbstractDependentResource` will +never try to create the associated secondary resource, even if it doesn't exist. It is even +possible to not implement any of these traits and therefore create read-only dependent resources +that will trigger your reconciler whenever a user interacts with them but that are never +modified by your reconciler itself - however note that read-only dependent resources rarely make +sense, as it is usually simpler to register an event source for the target resource. + +All subclasses +of [`AbstractDependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java) +can also implement +the [`Matcher`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Matcher.java) +interface to customize how the SDK decides whether or not the actual state of the dependent +matches the desired state. This makes it convenient to use these abstract base classes for your +implementation, only customizing the matching logic. Note that in many cases, there is no need +to customize that logic as the SDK already provides convenient default implementations in the +form +of [`DesiredEqualsMatcher`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DesiredEqualsMatcher.java) +and +[`GenericKubernetesResourceMatcher`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java) +implementations, respectively. If you want to provide custom logic, you only need your +`DependentResource` implementation to implement the `Matcher` interface as below, which shows +how to customize the default matching logic for Kubernetes resources to also consider annotations +and labels, which are ignored by default: + +```java +public class MyDependentResource extends KubernetesDependentResource + implements Matcher { + // your implementation + + public Result match(MyDependent actualResource, MyPrimary primary, + Context context) { + return GenericKubernetesResourceMatcher.match(this, actualResource, primary, context, true); + } +} +``` + +### Batteries included: convenient DependentResource implementations! + +JOSDK also offers several other convenient implementations building on top of +`AbstractDependentResource` that you can use as starting points for your own implementations. + +One such implementation is the `KubernetesDependentResource` class that makes it really easy to work +with Kubernetes-native resources. In this case, you usually only need to provide an implementation +for the `desired` method to tell JOSDK what the desired state of your secondary resource should +be based on the specified primary resource state. + +JOSDK takes care of everything else using default implementations that you can override in case you +need more precise control of what's going on. + +We also provide implementations that make it easy to cache +(`AbstractExternalDependentResource`) or poll for changes in external resources +(`PollingDependentResource`, `PerResourcePollingDependentResource`). All the provided +implementations can be found in the `io/javaoperatorsdk/operator/processing/dependent` package of +the `operator-framework-core` module. + +### Sample Kubernetes Dependent Resource + +A typical use case, when a Kubernetes resource is fully managed - Created, Read, Updated and +Deleted (or set to be garbage collected). The following example shows how to create a +`Deployment` dependent resource: + +```java + +@KubernetesDependent(informer = @Informer(labelSelector = SELECTOR)) +class DeploymentDependentResource extends CRUDKubernetesDependentResource { + + @Override + protected Deployment desired(WebPage webPage, Context context) { + var deploymentName = deploymentName(webPage); + Deployment deployment = loadYaml(Deployment.class, getClass(), "deployment.yaml"); + deployment.getMetadata().setName(deploymentName); + deployment.getMetadata().setNamespace(webPage.getMetadata().getNamespace()); + deployment.getSpec().getSelector().getMatchLabels().put("app", deploymentName); + + deployment.getSpec().getTemplate().getMetadata().getLabels() + .put("app", deploymentName); + deployment.getSpec().getTemplate().getSpec().getVolumes().get(0) + .setConfigMap(new ConfigMapVolumeSourceBuilder().withName(configMapName(webPage)).build()); + return deployment; + } +} +``` + +The only thing that you need to do is to extend the `CRUDKubernetesDependentResource` and +specify the desired state for your secondary resources based on the state of the primary one. In +the example above, we're handling the state of a `Deployment` secondary resource associated with +a `WebPage` custom (primary) resource. + +The `@KubernetesDependent` annotation can be used to further configure **managed** dependent +resource that are extending `KubernetesDependentResource`. + +See the full source +code [here](https://github.com/operator-framework/java-operator-sdk/blob/main/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/DeploymentDependentResource.java) +. + +## Managed Dependent Resources + +As mentioned previously, one goal of this implementation is to make it possible to declaratively +create and wire dependent resources. You can annotate your reconciler with `@Dependent` +annotations that specify which `DependentResource` implementation it depends upon. +JOSDK will take the appropriate steps to wire everything together and call your +`DependentResource` implementations `reconcile` method before your primary resource is reconciled. +This makes sense in most use cases where the logic associated with the primary resource is +usually limited to status handling based on the state of the secondary resources and the +resources are not dependent on each other. As an alternative, you can also invoke reconciliation explicitly, +event for managed workflows. + +See [Workflows](https://javaoperatorsdk.io/docs/documentation/dependent-resource-and-workflows/workflows/) for more details on how the dependent +resources are reconciled. + +This behavior and automated handling is referred to as "managed" because the `DependentResource` +instances are managed by JOSDK, an example of which can be seen below: + +```java + +@Workflow( + dependents = { + @Dependent(type = ConfigMapDependentResource.class), + @Dependent(type = DeploymentDependentResource.class), + @Dependent(type = ServiceDependentResource.class), + @Dependent( + type = IngressDependentResource.class, + reconcilePrecondition = ExposedIngressCondition.class) + }) +public class WebPageManagedDependentsReconciler + implements Reconciler, ErrorStatusHandler { + + // omitted code + + @Override + public UpdateControl reconcile(WebPage webPage, Context context) { + + final var name = context.getSecondaryResource(ConfigMap.class).orElseThrow() + .getMetadata().getName(); + webPage.setStatus(createStatus(name)); + return UpdateControl.patchStatus(webPage); + } +} +``` + +See the full source code of +sample [here](https://github.com/operator-framework/java-operator-sdk/blob/main/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageManagedDependentsReconciler.java) +. + +## Standalone Dependent Resources + +It is also possible to wire dependent resources programmatically. In practice this means that the +developer is responsible for initializing and managing the dependent resources as well as calling +their `reconcile` method. However, this makes it possible for developers to fully customize the +reconciliation process. Standalone dependent resources should be used in cases when the managed use +case does not fit. You can, of course, also use [Workflows](https://javaoperatorsdk.io/docs/documentation/dependent-resource-and-workflows/workflows/) when managing +resources programmatically. + +You can see a commented example of how to do +so [here](https://github.com/operator-framework/java-operator-sdk/blob/main/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageStandaloneDependentsReconciler.java). + +## Creating/Updating Kubernetes Resources + +From version 4.4 of the framework the resources are created and updated +using [Server Side Apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) +, thus the desired state is simply sent using this approach to update the actual resource. + +## Comparing desired and actual state (matching) + +During the reconciliation of a dependent resource, the desired state is matched with the actual +state from the caches. The dependent resource only gets updated on the server if the actual, +observed state differs from the desired one. Comparing these two states is a complex problem +when dealing with Kubernetes resources because a strict equality check is usually not what is +wanted due to the fact that multiple fields might be automatically updated or added by +the platform ( +by [dynamic admission controllers](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/) +or validation webhooks, for example). Solving this problem in a generic way is therefore a tricky +proposition. + +JOSDK provides such a generic matching implementation which is used by default: +[SSABasedGenericKubernetesResourceMatcher](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java) +This implementation relies on the managed fields used by the Server Side Apply feature to +compare only the values of the fields that the controller manages. This ensures that only +semantically relevant fields are compared. See javadoc for further details. + +JOSDK versions prior to 4.4 were using a different matching algorithm as implemented in +[GenericKubernetesResourceMatcher](https://github.com/java-operator-sdk/java-operator-sdk/blob/e16559fd41bbb8bef6ce9d1f47bffa212a941b09/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java). + +Since SSA is a complex feature, JOSDK implements a feature flag allowing users to switch between +these implementations. See +in [ConfigurationService](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L332-L358). + +It is, however, important to note that these implementations are default, generic +implementations that the framework can provide expected behavior out of the box. In many +situations, these will work just fine but it is also possible to provide matching algorithms +optimized for specific use cases. This is easily done by simply overriding +the `match(...)` [method](https://github.com/java-operator-sdk/java-operator-sdk/blob/e16559fd41bbb8bef6ce9d1f47bffa212a941b09/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java#L156-L156). + +It is also possible to bypass the matching logic altogether to simply rely on the server-side +apply mechanism if always sending potentially unchanged resources to the cluster is not an issue. +JOSDK's matching mechanism allows to spare some potentially useless calls to the Kubernetes API +server. To bypass the matching feature completely, simply override the `match` method to always +return `false`, thus telling JOSDK that the actual state never matches the desired one, making +it always update the resources using SSA. + +WARNING: Older versions of Kubernetes before 1.25 would create an additional resource version for every SSA update +performed with certain resources - even though there were no actual changes in the stored resource - leading to infinite +reconciliations. This behavior was seen with Secrets using `stringData`, Ingresses using empty string fields, and +StatefulSets using volume claim templates. The operator framework has added built-in handling for the StatefulSet issue. +If you encounter this issue on an older Kubernetes version, consider changing your desired state, turning off SSA for +that resource, or even upgrading your Kubernetes version. If you encounter it on a newer Kubernetes version, please log +an issue with the JOSDK and with upstream Kubernetes. + +## Telling JOSDK how to find which secondary resources are associated with a given primary resource + +[`KubernetesDependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java) +automatically maps secondary resource to a primary by owner reference. This behavior can be +customized by implementing +[`SecondaryToPrimaryMapper`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java) +by the dependent resource. + +See sample in one of the integration +tests [here](https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primaryindexer) +. + +## Multiple Dependent Resources of Same Type + +When dealing with multiple dependent resources of same type, the dependent resource implementation +needs to know which specific resource should be targeted when reconciling a given dependent +resource, since there could be multiple instances of that type which could possibly be used, each +associated with the same primary resource. In this situation, JOSDK automatically selects the appropriate secondary +resource matching the desired state associated with the primary resource. This makes sense because the desired +state computation already needs to be able to discriminate among multiple related secondary resources to tell JOSDK how +they should be reconciled. + +There might be cases, though, where it might be problematic to call the `desired` method several times (for example, because it is costly to do so), +it is always possible to override this automated discrimination using several means (consider in this priority order): + +- Override the `targetSecondaryResourceID` method, if your `DependentResource` extends `KubernetesDependentResource`, + where it's very often possible to easily determine the `ResourceID` of the secondary resource. This would probably be + the easiest solution if you're working with Kubernetes resources. +- Override the `selectTargetSecondaryResource` method, if your `DependentResource` extends `AbstractDependentResource`. + This should be relatively simple to override this method to optimize the matching to your needs. You can see an + example of such an implementation in + the [`ExternalWithStateDependentResource`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalWithStateDependentResource.java) + class. +- As last resort, you can implement your own `getSecondaryResource` method on your `DependentResource` implementation from scratch. + +### Sharing an Event Source Between Dependent Resources + +Dependent resources usually also provide event sources. When dealing with multiple dependents of +the same type, one needs to decide whether these dependent resources should track the same +resources and therefore share a common event source, or, to the contrary, track completely +separate resources, in which case using separate event sources is advised. + +Dependents can therefore reuse existing, named event sources by referring to their name. In the +declarative case, assuming a `configMapSource` `EventSource` has already been declared, this +would look as follows: + +``` + @Dependent(type = MultipleManagedDependentResourceConfigMap1.class, + useEventSourceWithName = "configMapSource") +``` + +A sample is provided as an integration test both: +for [managed](https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator) + +For [standalone](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource) +cases. + +## Bulk Dependent Resources + +So far, all the cases we've considered were dealing with situations where the number of +dependent resources needed to reconcile the state expressed by the primary resource is known +when writing the code for the operator. There are, however, cases where the number of dependent +resources to be created depends on information found in the primary resource. + +These cases are covered by the "bulk" dependent resources feature. To create such dependent +resources, your implementation should extend `AbstractDependentResource` (at least indirectly) and +implement the +[`BulkDependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResource.java) +interface. + +Various examples are provided +as [integration tests](https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent) +. + +To see how bulk dependent resources interact with workflow conditions, please refer to this +[integration test](https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/conidition). + +## External State Tracking Dependent Resources + +It is sometimes necessary for a controller to track external (i.e. non-Kubernetes) state to +properly manage some dependent resources. For example, your controller might need to track the +state of a REST API resource, which, after being created, would be refer to by its identifier. +Such identifier would need to be tracked by your controller to properly retrieve the state of +the associated resource and/or assess if such a resource exists. While there are several ways to +support this use case, we recommend storing such information in a dedicated Kubernetes resources +(usually a `ConfigMap` or a `Secret`), so that it can be manipulated with common Kubernetes +mechanisms. + +This particular use case is supported by the +[`AbstractExternalDependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java) +class that you can extend to suit your needs, as well as implement the +[`DependentResourceWithExplicitState`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DependentResourceWithExplicitState.java) +interface. Note that most of the JOSDK-provided dependent resource implementations such as +`PollingDependentResource` or `PerResourcePollingDependentResource` already extends +`AbstractExternalDependentResource`, thus supporting external state tracking out of the box. + +See [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateDependentIT.java) +as a sample. + +For a better understanding it might be worth to study +a [sample implementation](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java) +without dependent resources. + +Please also refer to the [docs](/docs/patterns-and-best-practices#managing-state) for managing state in +general. + +## Combining Bulk and External State Tracking Dependent Resources + +Both bulk and external state tracking features can be combined. In that +case, a separate, state-tracking resource will be created for each bulk dependent resource +created. For example, if three bulk dependent resources associated with external state are created, +three associated `ConfigMaps` (assuming `ConfigMaps` are used as a state-tracking resource) will +also be created, one per dependent resource. + +See [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent) +as a sample. + +## GenericKubernetesResource based Dependent Resources + +In rare circumstances resource handling where there is no class representation or just typeless handling might be +needed. +Fabric8 Client +provides [GenericKubernetesResource](https://github.com/fabric8io/kubernetes-client/blob/main/doc/CHEATSHEET.md#resource-typeless-api) +to support that. + +For dependent resource this is supported +by [GenericKubernetesDependentResource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesDependentResource.java#L8-L8) +. See +samples [here](https://github.com/java-operator-sdk/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/generickubernetesresource). + +## Other Dependent Resource Features + +### Caching and Event Handling in [KubernetesDependentResource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java) + +1. When a Kubernetes resource is created or updated the related informer (more precisely + the `InformerEventSource`), eventually will receive an event and will cache the up-to-date + resource. Typically, though, there might be a small time window when calling the + `getResource()` of the dependent resource or getting the resource from the `EventSource` + itself won't return the just updated resource, in the case where the associated event hasn't + been received from the Kubernetes API. The `KubernetesDependentResource` implementation, + however, addresses this issue, so you don't have to worry about it by making sure that it or + the related `InformerEventSource` always return the up-to-date resource. + +2. Another feature of `KubernetesDependentResource` is to make sure that if a resource is created or + updated during the reconciliation, this particular change, which normally would trigger the + reconciliation again (since the resource has changed on the server), will, in fact, not + trigger the reconciliation again since we already know the state is as expected. This is a small + optimization. For example if during a reconciliation a `ConfigMap` is updated using dependent + resources, this won't trigger a new reconciliation. Such a reconciliation is indeed not + needed since the change originated from our reconciler. For this system to work properly, + though, it is required that changes are received only by one event source (this is a best + practice in general) - so for example if there are two config map dependents, either + there should be a shared event source between them, or a label selector on the event sources + to select only the relevant events, see + in [related integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/ConfigMapDependentResource2.java) + . + +## "Read-only" Dependent Resources vs. Event Source + +See Integration test for a read-only +dependent [here](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/ConfigMapDependent.java). + +Some secondary resources only exist as input for the reconciliation process and are never +updated *by a controller* (they might, and actually usually do, get updated by users interacting +with the resources directly, however). This might be the case, for example, of a `ConfigMap`that is +used to configure common characteristics of multiple resources in one convenient place. + +In such situations, one might wonder whether it makes sense to create a dependent resource in +this case or simply use an `EventSource` so that the primary resource gets reconciled whenever a +user changes the resource. Typical dependent resources provide a desired state that the +reconciliation process attempts to match. In the case of so-called read-only dependents, though, +there is no such desired state because the operator / controller will never update the resource +itself, just react to external changes to it. An `EventSource` would achieve the same result. + +Using a dependent resource for that purpose instead of a simple `EventSource`, however, provides +several benefits: + +- dependents can be created declaratively, while an event source would need to be manually created +- if dependents are already used in a controller, it makes sense to unify the handling of all + secondary resources as dependents from a code organization perspective +- dependent resources can also interact with the workflow feature, thus allowing the read-only + resource to participate in conditions, in particular to decide whether the primary + resource needs/can be reconciled using reconcile pre-conditions, block the progression of the workflow altogether with + ready post-conditions or have other dependents depend on them, in essence, read-only dependents can participate in + workflows just as any other dependents. diff --git a/docs/content/en/docs/documentation/dependent-resource-and-workflows/workflows.md b/docs/content/en/docs/documentation/dependent-resource-and-workflows/workflows.md new file mode 100644 index 0000000000..c5ee83a446 --- /dev/null +++ b/docs/content/en/docs/documentation/dependent-resource-and-workflows/workflows.md @@ -0,0 +1,403 @@ +--- +title: Workflows +weight: 80 +--- + +## Overview + +Kubernetes (k8s) does not have the notion of a resource "depending on" on another k8s resource, +at least not in terms of the order in which these resources should be reconciled. Kubernetes +operators typically need to reconcile resources in order because these resources' state often +depends on the state of other resources or cannot be processed until these other resources reach +a given state or some condition holds true for them. Dealing with such scenarios are therefore +rather common for operators and the purpose of the workflow feature of the Java Operator SDK +(JOSDK) is to simplify supporting such cases in a declarative way. Workflows build on top of the +[dependent resources](https://javaoperatorsdk.io/docs/documentation/dependent-resource-and-workflows/dependent-resources/) feature. +While dependent resources focus on how a given secondary resource should be reconciled, +workflows focus on orchestrating how these dependent resources should be reconciled. + +Workflows describe how as a set of +[dependent resources](https://javaoperatorsdk.io/docs/documentation/dependent-resource-and-workflows/dependent-resources/) (DR) depend on one +another, along with the conditions that need to hold true at certain stages of the +reconciliation process. + +## Elements of Workflow + +- **Dependent resource** (DR) - are the resources being managed in a given reconciliation logic. +- **Depends-on relation** - a `B` DR depends on another `A` DR if `B` needs to be reconciled + after `A`. +- **Reconcile precondition** - is a condition on a given DR that needs to be become true before the + DR is reconciled. This also allows to define optional resources that would, for example, only be + created if a flag in a custom resource `.spec` has some specific value. +- **Ready postcondition** - is a condition on a given DR to prevent the workflow from + proceeding until the condition checking whether the DR is ready holds true +- **Delete postcondition** - is a condition on a given DR to check if the reconciliation of + dependents can proceed after the DR is supposed to have been deleted +- **Activation condition** - is a special condition meant to specify under which condition the DR is used in the + workflow. A typical use-case for this feature is to only activate some dependents depending on the presence of + optional resources / features on the target cluster. Without this activation condition, JOSDK would attempt to + register an informer for these optional resources, which would cause an error in the case where the resource is + missing. With this activation condition, you can now conditionally register informers depending on whether the + condition holds or not. This is a very useful feature when your operator needs to handle different flavors of the + platform (e.g. OpenShift vs plain Kubernetes) and/or change its behavior based on the availability of optional + resources / features (e.g. CertManager, a specific Ingress controller, etc.). + + A generic activation condition is provided out of the box, called + [CRDPresentActivationCondition](https://github.com/operator-framework/java-operator-sdk/blob/ba5e33527bf9e3ea0bd33025ccb35e677f9d44b4/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationCondition.java) + that will prevent the associated dependent resource from being activated if the Custom Resource Definition associated + with the dependent's resource type is not present on the cluster. + See related [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation). + + To have multiple resources of same type with an activation condition is a bit tricky, since you + don't want to have multiple `InformerEventSource` for the same type, you have to explicitly + name the informer for the Dependent Resource (`@KubernetesDependent(informerConfig = @InformerConfig(name = "configMapInformer"))`) + for all resource of same type with activation condition. This will make sure that only one is registered. + See details at [low level api](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceRetriever.java#L20-L52). + +### Result conditions + +While simple conditions are usually enough, it might happen you want to convey extra information as a result of the +evaluation of the conditions (e.g., to report error messages or because the result of the condition evaluation might be +interesting for other purposes). In this situation, you should implement `DetailedCondition` instead of `Condition` and +provide an implementation of the `detailedIsMet` method, which allows you to return a more detailed `Result` object via +which you can provide extra information. The `DetailedCondition.Result` interface provides factory method for your +convenience but you can also provide your own implementation if required. + +You can access the results for conditions from the `WorkflowResult` instance that is returned whenever a workflow is +evaluated. You can access that result from the `ManagedWorkflowAndDependentResourceContext` accessible from the +reconciliation `Context`. You can then access individual condition results using the ` +getDependentConditionResult` methods. You can see an example of this +in [this integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureReconciler.java). + +## Defining Workflows + +Similarly to dependent resources, there are two ways to define workflows, in managed and standalone +manner. + +### Managed + +Annotations can be used to declaratively define a workflow for a `Reconciler`. Similarly to how +things are done for dependent resources, managed workflows execute before the `reconcile` method +is called. The result of the reconciliation can be accessed via the `Context` object that is +passed to the `reconcile` method. + +The following sample shows a hypothetical use case to showcase all the elements: the primary +`TestCustomResource` resource handled by our `Reconciler` defines two dependent resources, a +`Deployment` and a `ConfigMap`. The `ConfigMap` depends on the `Deployment` so will be +reconciled after it. Moreover, the `Deployment` dependent resource defines a ready +post-condition, meaning that the `ConfigMap` will not be reconciled until the condition defined +by the `Deployment` becomes `true`. Additionally, the `ConfigMap` dependent also defines a +reconcile pre-condition, so it also won't be reconciled until that condition becomes `true`. The +`ConfigMap` also defines a delete post-condition, which means that the workflow implementation +will only consider the `ConfigMap` deleted until that post-condition becomes `true`. + +```java + +@Workflow(dependents = { + @Dependent(name = DEPLOYMENT_NAME, type = DeploymentDependentResource.class, + readyPostcondition = DeploymentReadyCondition.class), + @Dependent(type = ConfigMapDependentResource.class, + reconcilePrecondition = ConfigMapReconcileCondition.class, + deletePostcondition = ConfigMapDeletePostCondition.class, + activationCondition = ConfigMapActivationCondition.class, + dependsOn = DEPLOYMENT_NAME) +}) +@ControllerConfiguration +public class SampleWorkflowReconciler implements Reconciler, + Cleaner { + + public static final String DEPLOYMENT_NAME = "deployment"; + + @Override + public UpdateControl reconcile( + WorkflowAllFeatureCustomResource resource, + Context context) { + + resource.getStatus() + .setReady( + context.managedWorkflowAndDependentResourceContext() // accessing workflow reconciliation results + .getWorkflowReconcileResult() + .allDependentResourcesReady()); + return UpdateControl.patchStatus(resource); + } + + @Override + public DeleteControl cleanup(WorkflowAllFeatureCustomResource resource, + Context context) { + // emitted code + + return DeleteControl.defaultDelete(); + } +} + +``` + +### Standalone + +In this mode workflow is built manually +using [standalone dependent resources](https://javaoperatorsdk.io/docs/documentation/dependent-resource-and-workflows/dependent-resources/#standalone-dependent-resources) +. The workflow is created using a builder, that is explicitly called in the reconciler (from web +page sample): + +```java + +@ControllerConfiguration( + labelSelector = WebPageDependentsWorkflowReconciler.DEPENDENT_RESOURCE_LABEL_SELECTOR) +public class WebPageDependentsWorkflowReconciler + implements Reconciler, ErrorStatusHandler { + + public static final String DEPENDENT_RESOURCE_LABEL_SELECTOR = "!low-level"; + private static final Logger log = + LoggerFactory.getLogger(WebPageDependentsWorkflowReconciler.class); + + private KubernetesDependentResource configMapDR; + private KubernetesDependentResource deploymentDR; + private KubernetesDependentResource serviceDR; + private KubernetesDependentResource ingressDR; + + private final Workflow workflow; + + public WebPageDependentsWorkflowReconciler(KubernetesClient kubernetesClient) { + initDependentResources(kubernetesClient); + workflow = new WorkflowBuilder() + .addDependentResource(configMapDR) + .addDependentResource(deploymentDR) + .addDependentResource(serviceDR) + .addDependentResource(ingressDR).withReconcilePrecondition(new ExposedIngressCondition()) + .build(); + } + + @Override + public Map prepareEventSources(EventSourceContext context) { + return EventSourceUtils.nameEventSources( + configMapDR.initEventSource(context), + deploymentDR.initEventSource(context), + serviceDR.initEventSource(context), + ingressDR.initEventSource(context)); + } + + @Override + public UpdateControl reconcile(WebPage webPage, Context context) { + + var result = workflow.reconcile(webPage, context); + + webPage.setStatus(createStatus(result)); + return UpdateControl.patchStatus(webPage); + } + // omitted code +} + +``` + +## Workflow Execution + +This section describes how a workflow is executed in details, how the ordering is determined and +how conditions and errors affect the behavior. The workflow execution is divided in two parts +similarly to how `Reconciler` and `Cleaner` behavior are separated. +[Cleanup](https://javaoperatorsdk.io/docs/documentation/reconciler/#implementing-a-reconciler-and-cleaner-interfaces) is +executed if a resource is marked for deletion. + +## Common Principles + +- **As complete as possible execution** - when a workflow is reconciled, it tries to reconcile as + many resources as possible. Thus, if an error happens or a ready condition is not met for a + resources, all the other independent resources will be still reconciled. This is the opposite + to a fail-fast approach. The assumption is that eventually in this way the overall state will + converge faster towards the desired state than would be the case if the reconciliation was + aborted as soon as an error occurred. +- **Concurrent reconciliation of independent resources** - the resources which doesn't depend on + others are processed concurrently. The level of concurrency is customizable, could be set to + one if required. By default, workflows use the executor service + from [ConfigurationService](https://github.com/java-operator-sdk/java-operator-sdk/blob/6f2a252952d3a91f6b0c3c38e5e6cc28f7c0f7b3/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L120-L120) + +## Reconciliation + +This section describes how a workflow is executed, considering first which rules apply, then +demonstrated using examples: + +### Rules + +1. A workflow is a Directed Acyclic Graph (DAG) build from the DRs and their associated + `depends-on` relations. +2. Root nodes, i.e. nodes in the graph that do not depend on other nodes are reconciled first, + in a parallel manner. +3. A DR is reconciled if it does not depend on any other DRs, or *ALL* the DRs it depends on are + reconciled and ready. If a DR defines a reconcile pre-condition and/or an activation condition, + then these condition must become `true` before the DR is reconciled. +4. A DR is considered *ready* if it got successfully reconciled and any ready post-condition it + might define is `true`. +5. If a DR's reconcile pre-condition is not met, this DR is deleted. All the DRs that depend + on the dependent resource are also recursively deleted. This implies that + DRs are deleted in reverse order compared the one in which they are reconciled. The reason + for this behavior is (Will make a more detailed blog post about the design decision, much deeper + than the reference documentation) + The reasoning behind this behavior is as follows: a DR with a reconcile pre-condition is only + reconciled if the condition holds `true`. This means that if the condition is `false` and the + resource didn't exist already, then the associated resource would not be created. To ensure + idempotency (i.e. with the same input state, we should have the same output state), from this + follows that if the condition doesn't hold `true` anymore, the associated resource needs to + be deleted because the resource shouldn't exist/have been created. +6. If a DR's activation condition is not met, it won't be reconciled or deleted. If other DR's depend on it, those will + be recursively deleted in a way similar to reconcile pre-conditions. Event sources for a dependent resource with + activation condition are registered/de-registered dynamically, thus during the reconciliation. +7. For a DR to be deleted by a workflow, it needs to implement the `Deleter` interface, in which + case its `delete` method will be called, unless it also implements the `GarbageCollected` + interface. If a DR doesn't implement `Deleter` it is considered as automatically deleted. If + a delete post-condition exists for this DR, it needs to become `true` for the workflow to + consider the DR as successfully deleted. + +### Samples + +Notation: The arrows depicts reconciliation ordering, thus following the reverse direction of the +`depends-on` relation: +`1 --> 2` mean `DR 2` depends-on `DR 1`. + +#### Reconcile Sample + +```mermaid +stateDiagram-v2 +1 --> 2 +1 --> 3 +2 --> 4 +3 --> 4 +``` + + +- Root nodes (i.e. nodes that don't depend on any others) are reconciled first. In this example, + DR `1` is reconciled first since it doesn't depend on others. + After that both DR `2` and `3` are reconciled concurrently, then DR `4` once both are + reconciled successfully. +- If DR `2` had a ready condition and if it evaluated to as `false`, DR `4` would not be reconciled. + However `1`,`2` and `3` would be. +- If `1` had a `false` ready condition, neither `2`,`3` or `4` would be reconciled. +- If `2`'s reconciliation resulted in an error, `4` would not be reconciled, but `3` + would be (and `1` as well, of course). + +#### Sample with Reconcile Precondition + + +```mermaid +stateDiagram-v2 +1 --> 2 +1 --> 3 +3 --> 4 +3 --> 5 +``` + + +- If `3` has a reconcile pre-condition that is not met, `1` and `2` would be reconciled. However, + DR `3`,`4`,`5` would be deleted: `4` and `5` would be deleted concurrently but `3` would only + be deleted if `4` and `5` were deleted successfully (i.e. without error) and all existing + delete post-conditions were met. +- If `5` had a delete post-condition that was `false`, `3` would not be deleted but `4` + would still be because they don't depend on one another. +- Similarly, if `5`'s deletion resulted in an error, `3` would not be deleted but `4` would be. + +## Cleanup + +Cleanup works identically as delete for resources in reconciliation in case reconcile pre-condition +is not met, just for the whole workflow. + +### Rules + +1. Delete is called on a DR if there is no DR that depends on it +2. If a DR has DRs that depend on it, it will only be deleted if all these DRs are successfully + deleted without error and any delete post-condition is `true`. +3. A DR is "manually" deleted (i.e. it's `Deleter.delete` method is called) if it implements the + `Deleter` interface but does not implement `GarbageCollected`. If a DR does not implement + `Deleter` interface, it is considered as deleted automatically. + +### Sample + +```mermaid +stateDiagram-v2 +1 --> 2 +1 --> 3 +2 --> 4 +3 --> 4 +``` + +- The DRs are deleted in the following order: `4` is deleted first, then `2` and `3` are deleted + concurrently, and, only after both are successfully deleted, `1` is deleted. +- If `2` had a delete post-condition that was `false`, `1` would not be deleted. `4` and `3` + would be deleted. +- If `2` was in error, DR `1` would not be deleted. DR `4` and `3` would be deleted. +- if `4` was in error, no other DR would be deleted. + +## Error Handling + +As mentioned before if an error happens during a reconciliation, the reconciliation of other +dependent resources will still happen, assuming they don't depend on the one that failed. If +case multiple DRs fail, the workflow would throw an +['AggregatedOperatorException'](https://github.com/java-operator-sdk/java-operator-sdk/blob/86e5121d56ed4ecb3644f2bc8327166f4f7add72/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/AggregatedOperatorException.java) +containing all the related exceptions. + +The exceptions can be handled +by [`ErrorStatusHandler`](https://github.com/java-operator-sdk/java-operator-sdk/blob/14620657fcacc8254bb96b4293eded84c20ba685/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ErrorStatusHandler.java) + +## Waiting for the actual deletion of Kubernetes Dependent Resources + +Let's consider a case when a Kubernetes Dependent Resources (KDR) depends on another resource, on cleanup +the resources will be deleted in reverse order, thus the KDR will be deleted first. +However, the workflow implementation currently simply asks the Kubernetes API server to delete the resource. This is, +however, an asynchronous process, meaning that the deletion might not occur immediately, in particular if the resource +uses finalizers that block the deletion or if the deletion itself takes some time. From the SDK's perspective, though, +the deletion has been requested and it moves on to other tasks without waiting for the resource to be actually deleted +from the server (which might never occur if it uses finalizers which are not removed). +In situations like these, if your logic depends on resources being actually removed from the cluster before a +cleanup workflow can proceed correctly, you need to block the workflow progression using a delete post-condition that +checks that the resource is actually removed or that it, at least, doesn't have any finalizers any longer. JOSDK +provides such a delete post-condition implementation in the form of +[`KubernetesResourceDeletedCondition`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/KubernetesResourceDeletedCondition.java) + +Also, check usage in an [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ManagedDependentDefaultDeleteConditionReconciler.java). + +In such cases the Kubernetes Dependent Resource should extend `CRUDNoGCKubernetesDependentResource` +and NOT `CRUDKubernetesDependentResource` since otherwise the Kubernetes Garbage Collector would delete the resources. +In other words if a Kubernetes Dependent Resource depends on another dependent resource, it should not implement +`GargageCollected` interface, otherwise the deletion order won't be guaranteed. + + +## Explicit Managed Workflow Invocation + +Managed workflows, i.e. ones that are declared via annotations and therefore completely managed by JOSDK, are reconciled +before the primary resource. Each dependent resource that can be reconciled (according to the workflow configuration) +will therefore be reconciled before the primary reconciler is called to reconcile the primary resource. There are, +however, situations where it would be be useful to perform additional steps before the workflow is reconciled, for +example to validate the current state, execute arbitrary logic or even skip reconciliation altogether. Explicit +invocation of managed workflow was therefore introduced to solve these issues. + +To use this feature, you need to set the `explicitInvocation` field to `true` on the `@Workflow` annotation and then +call the `reconcileManagedWorkflow` method from the ` +ManagedWorkflowAndDependentResourceContext` retrieved from the reconciliation `Context` provided as part of your primary +resource reconciler `reconcile` method arguments. + +See +related [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation) +for more details. + +For `cleanup`, if the `Cleaner` interface is implemented, the `cleanupManageWorkflow()` needs to be called explicitly. +However, if `Cleaner` interface is not implemented, it will be called implicitly. +See +related [integration test](https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup). + +While nothing prevents calling the workflow multiple times in a reconciler, it isn't typical or even recommended to do +so. Conversely, if explicit invocation is requested but `reconcileManagedWorkflow` is not called in the primary resource +reconciler, the workflow won't be reconciled at all. + +## Notes and Caveats + +- Delete is almost always called on every resource during the cleanup. However, it might be the case + that the resources were already deleted in a previous run, or not even created. This should + not be a problem, since dependent resources usually cache the state of the resource, so are + already aware that the resource does not exist and that nothing needs to be done if delete is + called. +- If a resource has owner references, it will be automatically deleted by the Kubernetes garbage + collector if the owner resource is marked for deletion. This might not be desirable, to make + sure that delete is handled by the workflow don't use garbage collected kubernetes dependent + resource, use for + example [`CRUDNoGCKubernetesDependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/86e5121d56ed4ecb3644f2bc8327166f4f7add72/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/CRUDNoGCKubernetesDependentResource.java) + . +- No state is persisted regarding the workflow execution. Every reconciliation causes all the + resources to be reconciled again, in other words the whole workflow is again evaluated. + diff --git a/docs/content/en/docs/documentation/error-handling-retries.md b/docs/content/en/docs/documentation/error-handling-retries.md new file mode 100644 index 0000000000..eeecf54751 --- /dev/null +++ b/docs/content/en/docs/documentation/error-handling-retries.md @@ -0,0 +1,146 @@ +--- +title: Error handling and retries +weight: 46 +--- + +## How Automatic Retries Work + +JOSDK automatically schedules retries whenever your `Reconciler` throws an exception. This robust retry mechanism helps handle transient issues like network problems or temporary resource unavailability. + +### Default Retry Behavior + +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 +``` + +### Configuration Options + +**Using the `@GradualRetry` annotation:** + +```java +@ControllerConfiguration +@GradualRetry(maxAttempts = 3, initialInterval = 2000) +public class MyReconciler implements Reconciler { + // reconciler implementation +} +``` + +**Custom retry implementation:** + +Specify a custom retry class in the `@ControllerConfiguration` annotation: + +```java +@ControllerConfiguration(retry = MyCustomRetry.class) +public class MyReconciler implements Reconciler { + // reconciler implementation +} +``` + +Your custom retry class must: +- Provide a no-argument constructor for automatic instantiation +- Optionally implement `AnnotationConfigurable` for configuration from annotations. See [`GenericRetry`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java#) + implementation for more details. + +### Accessing Retry Information + +The [Context](https://github.com/java-operator-sdk/java-operator-sdk/blob/master/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/Context.java) object provides retry state information: + +```java +@Override +public UpdateControl reconcile(MyResource resource, Context context) { + if (context.isLastAttempt()) { + // Handle final retry attempt differently + resource.getStatus().setErrorMessage("Failed after all retry attempts"); + return UpdateControl.patchStatus(resource); + } + + // Normal reconciliation logic + // ... +} +``` + +### Important Retry Behavior Notes + +- **Retry limits don't block new events**: When retry limits are reached, new reconciliations still occur for new events +- **No retry on limit reached**: If an error occurs after reaching the retry limit, no additional retries are scheduled until new events arrive +- **Event-driven recovery**: Fresh events can restart the retry cycle, allowing recovery from previously failed states + +A successful execution resets the retry state. + +### Reconciler Error Handler + +In order to facilitate error reporting you can override [`updateErrorStatus`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Reconciler.java#L52) +method in `Reconciler`: + +```java +public class MyReconciler implements Reconciler { + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + WebPage resource, Context context, Exception e) { + return handleError(resource, e); + } + +} +``` + +The `updateErrorStatus` method is called in case an exception is thrown from the `Reconciler`. It is +also called even if no retry policy is configured, just after the reconciler execution. +`RetryInfo.getAttemptCount()` is zero after the first reconciliation attempt, since it is not a +result of a retry (regardless of whether a retry policy is configured). + +`ErrorStatusUpdateControl` tells the SDK what to do and how to perform the status +update on the primary resource, which is always performed as a status sub-resource request. Note that +this update request will also produce an event and result in a reconciliation if the +controller is not generation-aware. + +This feature is only available for the `reconcile` method of the `Reconciler` interface, since +there should not be updates to resources that have been marked for deletion. + +Retry can be skipped in cases of unrecoverable errors: + +```java + ErrorStatusUpdateControl.patchStatus(customResource).withNoRetry(); +``` + +### Correctness and Automatic Retries + +While it is possible to deactivate automatic retries, this is not desirable unless there is a particular reason. +Errors naturally occur, whether it be transient network errors or conflicts +when a given resource is handled by a `Reconciler` but modified simultaneously by a user in +a different process. Automatic retries handle these cases nicely and will eventually result in a +successful reconciliation. + +## Retry, Rescheduling and Event Handling Common Behavior + +Retry, reschedule, and standard event processing form a relatively complex system, each of these +functionalities interacting with the others. In the following, we describe the interplay of +these features: + +1. A successful execution resets a retry and the rescheduled executions that were present before + the reconciliation. However, the reconciliation outcome can instruct a new rescheduling (`UpdateControl` or `DeleteControl`). + + For example, if a reconciliation had previously been rescheduled for after some amount of time, but an event triggered + the reconciliation (or cleanup) in the meantime, the scheduled execution would be automatically cancelled, i.e. + rescheduling a reconciliation does not guarantee that one will occur precisely at that time; it simply guarantees that a reconciliation will occur at the latest. + Of course, it's always possible to reschedule a new reconciliation at the end of that "automatic" reconciliation. + + Similarly, if a retry was scheduled, any event from the cluster triggering a successful execution in the meantime + would cancel the scheduled retry (because there's now no point in retrying something that already succeeded) + +2. In case an exception is thrown, a retry is initiated. However, if an event is received + meanwhile, it will be reconciled instantly, and this execution won't count as a retry attempt. +3. If the retry limit is reached (so no more automatic retry would happen), but a new event + received, the reconciliation will still happen, but won't reset the retry, and will still be + marked as the last attempt in the retry info. The point (1) still holds - thus successful reconciliation will reset the retry - but no retry will happen in case of an error. + +The thing to remember when it comes to retrying or rescheduling is that JOSDK tries to avoid unnecessary work. When +you reschedule an operation, you instruct JOSDK to perform that operation by the end of the rescheduling +delay at the latest. If something occurred on the cluster that triggers that particular operation (reconciliation or cleanup), then +JOSDK considers that there's no point in attempting that operation again at the end of the specified delay since there +is no point in doing so anymore. The same idea also applies to retries. diff --git a/docs/content/en/docs/documentation/eventing.md b/docs/content/en/docs/documentation/eventing.md new file mode 100644 index 0000000000..77daeb6fa3 --- /dev/null +++ b/docs/content/en/docs/documentation/eventing.md @@ -0,0 +1,327 @@ +--- +title: Event sources and related topics +weight: 47 +--- + +## Handling Related Events with Event Sources + +See also +this [blog post](https://csviri.medium.com/java-operator-sdk-introduction-to-event-sources-a1aab5af4b7b) +. + +Event sources are a relatively simple yet powerful and extensible concept to trigger controller +executions, usually based on changes to dependent resources. You typically need an event source +when you want your `Reconciler` to be triggered when something occurs to secondary resources +that might affect the state of your primary resource. This is needed because a given +`Reconciler` will only listen by default to events affecting the primary resource type it is +configured for. Event sources act as listen to events affecting these secondary resources so +that a reconciliation of the associated primary resource can be triggered when needed. Note that +these secondary resources need not be Kubernetes resources. Typically, when dealing with +non-Kubernetes objects or services, we can extend our operator to handle webhooks or websockets +or to react to any event coming from a service we interact with. This allows for very efficient +controller implementations because reconciliations are then only triggered when something occurs +on resources affecting our primary resources thus doing away with the need to periodically +reschedule reconciliations. + +![Event Sources architecture diagram](/images/event-sources.png) + +There are few interesting points here: + +The `CustomResourceEventSource` event source is a special one, responsible for handling events +pertaining to changes affecting our primary resources. This `EventSource` is always registered +for every controller automatically by the SDK. It is important to note that events always relate +to a given primary resource. Concurrency is still handled for you, even in the presence of +`EventSource` implementations, and the SDK still guarantees that there is no concurrent execution of +the controller for any given primary resource (though, of course, concurrent/parallel executions +of events pertaining to other primary resources still occur as expected). + +### Caching and Event Sources + +Kubernetes resources are handled in a declarative manner. The same also holds true for event +sources. For example, if we define an event source to watch for changes of a Kubernetes Deployment +object using an `InformerEventSource`, we always receive the whole associated object from the +Kubernetes API. This object might be needed at any point during our reconciliation process and +it's best to retrieve it from the event source directly when possible instead of fetching it +from the Kubernetes API since the event source guarantees that it will provide the latest +version. Not only that, but many event source implementations also cache resources they handle +so that it's possible to retrieve the latest version of resources without needing to make any +calls to the Kubernetes API, thus allowing for very efficient controller implementations. + +Note after an operator starts, caches are already populated by the time the first reconciliation +is processed for the `InformerEventSource` implementation. However, this does not necessarily +hold true for all event source implementations (`PerResourceEventSource` for example). The SDK +provides methods to handle this situation elegantly, allowing you to check if an object is +cached, retrieving it from a provided supplier if not. See +related [method](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java#L146) +. + +### Registering Event Sources + +To register event sources, your `Reconciler` has to override the `prepareEventSources` and return +list of event sources to register. One way to see this in action is +to look at the +[WebPage example](https://github.com/operator-framework/java-operator-sdk/blob/main/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java) +(irrelevant details omitted): + +```java + +import java.util.List; + +@ControllerConfiguration +public class WebappReconciler + implements Reconciler, Cleaner, EventSourceInitializer { + // ommitted code + + @Override + public List> prepareEventSources(EventSourceContext context) { + InformerEventSourceConfiguration configuration = + InformerEventSourceConfiguration.from(Deployment.class, Webapp.class) + .withLabelSelector(SELECTOR) + .build(); + return List.of(new InformerEventSource<>(configuration, context)); + } +} +``` + +In the example above an `InformerEventSource` is configured and registered. +`InformerEventSource` is one of the bundled `EventSource` implementations that JOSDK provides to +cover common use cases. + +### Managing Relation between Primary and Secondary Resources + +Event sources let your operator know when a secondary resource has changed and that your +operator might need to reconcile this new information. However, in order to do so, the SDK needs +to somehow retrieve the primary resource associated with which ever secondary resource triggered +the event. In the `Webapp` example above, when an event occurs on a tracked `Deployment`, the +SDK needs to be able to identify which `Webapp` resource is impacted by that change. + +Seasoned Kubernetes users already know one way to track this parent-child kind of relationship: +using owner references. Indeed, that's how the SDK deals with this situation by default as well, +that is, if your controller properly set owner references on your secondary resources, the SDK +will be able to follow that reference back to your primary resource automatically without you +having to worry about it. + +However, owner references cannot always be used as they are restricted to operating within a +single namespace (i.e. you cannot have an owner reference to a resource in a different namespace) +and are, by essence, limited to Kubernetes resources so you're out of luck if your secondary +resources live outside of a cluster. + +This is why JOSDK provides the `SecondaryToPrimaryMapper` interface so that you can provide +alternative ways for the SDK to identify which primary resource needs to be reconciled when +something occurs to your secondary resources. We even provide some of these alternatives in the +[Mappers](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/Mappers.java) +class. + +Note that, while a set of `ResourceID` is returned, this set usually consists only of one +element. It is however possible to return multiple values or even no value at all to cover some +rare corner cases. Returning an empty set means that the mapper considered the secondary +resource event as irrelevant and the SDK will thus not trigger a reconciliation of the primary +resource in that situation. + +Adding a `SecondaryToPrimaryMapper` is typically sufficient when there is a one-to-many relationship +between primary and secondary resources. The secondary resources can be mapped to its primary +owner, and this is enough information to also get these secondary resources from the `Context` +object that's passed to your `Reconciler`. + +There are however cases when this isn't sufficient and you need to provide an explicit mapping +between a primary resource and its associated secondary resources using an implementation of the +`PrimaryToSecondaryMapper` interface. This is typically needed when there are many-to-one or +many-to-many relationships between primary and secondary resources, e.g. when the primary resource +is referencing secondary resources. +See [PrimaryToSecondaryIT](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/PrimaryToSecondaryIT.java) +integration test for a sample. + +### Built-in EventSources + +There are multiple event-sources provided out of the box, the following are some more central ones: + +#### `InformerEventSource` + +[InformerEventSource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java) +is probably the most important `EventSource` implementation to know about. When you create an +`InformerEventSource`, JOSDK will automatically create and register a `SharedIndexInformer`, a +fabric8 Kubernetes client class, that will listen for events associated with the resource type +you configured your `InformerEventSource` with. If you want to listen to Kubernetes resource +events, `InformerEventSource` is probably the only thing you need to use. It's highly +configurable so you can tune it to your needs. Take a look at +[InformerEventSourceConfiguration](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java) +and associated classes for more details but some interesting features we can mention here is the +ability to filter events so that you can only get notified for events you care about. A +particularly interesting feature of the `InformerEventSource`, as opposed to using your own +informer-based listening mechanism is that caches are particularly well optimized preventing +reconciliations from being triggered when not needed and allowing efficient operators to be written. + +#### `PerResourcePollingEventSource` + +[PerResourcePollingEventSource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java) +is used to poll external APIs, which don't support webhooks or other event notifications. It +extends the abstract +[ExternalResourceCachingEventSource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java) +to support caching. +See [MySQL Schema sample](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaReconciler.java) +for usage. + +#### `PollingEventSource` + +[PollingEventSource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java) +is similar to `PerResourceCachingEventSource` except that, contrary to that event source, it +doesn't poll a specific API separately per resource, but periodically and independently of +actually observed primary resources. + +#### Inbound event sources + +[SimpleInboundEventSource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/SimpleInboundEventSource.java) +and +[CachingInboundEventSource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/CachingInboundEventSource.java) +are used to handle incoming events from webhooks and messaging systems. + +#### `ControllerResourceEventSource` + +[ControllerResourceEventSource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSource.java) +is a special `EventSource` implementation that you will never have to deal with directly. It is, +however, at the core of the SDK is automatically added for you: this is the main event source +that listens for changes to your primary resources and triggers your `Reconciler` when needed. +It features smart caching and is really optimized to minimize Kubernetes API accesses and avoid +triggering unduly your `Reconciler`. + +More on the philosophy of the non Kubernetes API related event source see in +issue [#729](https://github.com/java-operator-sdk/java-operator-sdk/issues/729). + + +## InformerEventSource Multi-Cluster Support + +It is possible to handle resources for remote cluster with `InformerEventSource`. To do so, +simply set a client that connects to a remote cluster: + +```java + +InformerEventSourceConfiguration configuration = + InformerEventSourceConfiguration.from(SecondaryResource.class, PrimaryResource.class) + .withKubernetesClient(remoteClusterClient) + .withSecondaryToPrimaryMapper(Mappers.fromDefaultAnnotations()); + +``` + +You will also need to specify a `SecondaryToPrimaryMapper`, since the default one +is based on owner references and won't work across cluster instances. You could, for example, use the provided implementation that relies on annotations added to the secondary resources to identify the associated primary resource. + +See related [integration test](https://github.com/operator-framework/java-operator-sdk/tree/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster). + + +## Generation Awareness and Event Filtering + +A best practice when an operator starts up is to reconcile all the associated resources because +changes might have occurred to the resources while the operator was not running. + +When this first reconciliation is done successfully, the next reconciliation is triggered if either +dependent resources are changed or the primary resource `.spec` field is changed. If other fields +like `.metadata` are changed on the primary resource, the reconciliation could be skipped. This +behavior is supported out of the box and reconciliation is by default not triggered if +changes to the primary resource do not increase the `.metadata.generation` field. +Note that changes to `.metada.generation` are automatically handled by Kubernetes. + +To turn off this feature, set `generationAwareEventProcessing` to `false` for the `Reconciler`. + + +## Max Interval Between Reconciliations + +When informers / event sources are properly set up, and the `Reconciler` implementation is +correct, no additional reconciliation triggers should be needed. However, it's +a [common practice](https://github.com/java-operator-sdk/java-operator-sdk/issues/848#issuecomment-1016419966) +to have a failsafe periodic trigger in place, just to make sure resources are nevertheless +reconciled after a certain amount of time. This functionality is in place by default, with a +rather high time interval (currently 10 hours) after which a reconciliation will be +automatically triggered even in the absence of other events. See how to override this using the +standard annotation: + +```java +@ControllerConfiguration(maxReconciliationInterval = @MaxReconciliationInterval( + interval = 50, + timeUnit = TimeUnit.MILLISECONDS)) +public class MyReconciler implements Reconciler {} +``` + +The event is not propagated at a fixed rate, rather it's scheduled after each reconciliation. So the +next reconciliation will occur at most within the specified interval after the last reconciliation. + +This feature can be turned off by setting `maxReconciliationInterval` +to [`Constants.NO_MAX_RECONCILIATION_INTERVAL`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java#L20-L20) +or any non-positive number. + +The automatic retries are not affected by this feature so a reconciliation will be re-triggered +on error, according to the specified retry policy, regardless of this maximum interval setting. + +## Rate Limiting + +It is possible to rate limit reconciliation on a per-resource basis. The rate limit also takes +precedence over retry/re-schedule configurations: for example, even if a retry was scheduled for +the next second but this request would make the resource go over its rate limit, the next +reconciliation will be postponed according to the rate limiting rules. Note that the +reconciliation is never cancelled, it will just be executed as early as possible based on rate +limitations. + +Rate limiting is by default turned **off**, since correct configuration depends on the reconciler +implementation, in particular, on how long a typical reconciliation takes. +(The parallelism of reconciliation itself can be +limited [`ConfigurationService`](https://github.com/java-operator-sdk/java-operator-sdk/blob/ce4d996ee073ebef5715737995fc3d33f4751275/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L120-L120) +by configuring the `ExecutorService` appropriately.) + +A default rate limiter implementation is provided, see: +[`PeriodRateLimiter`](https://github.com/java-operator-sdk/java-operator-sdk/blob/ce4d996ee073ebef5715737995fc3d33f4751275/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/PeriodRateLimiter.java#L14-L14) +. +Users can override it by implementing their own +[`RateLimiter`](https://github.com/java-operator-sdk/java-operator-sdk/blob/ce4d996ee073ebef5715737995fc3d33f4751275/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/RateLimiter.java) +and specifying this custom implementation using the `rateLimiter` field of the +`@ControllerConfiguration` annotation. Similarly to the `Retry` implementations, +`RateLimiter` implementations must provide an accessible, no-arg constructor for instantiation +purposes and can further be automatically configured from your own, provided annotation provided +your `RateLimiter` implementation also implements the `AnnotationConfigurable` interface, +parameterized by your custom annotation type. + +To configure the default rate limiter use the `@RateLimited` annotation on your +`Reconciler` class. The following configuration limits each resource to reconcile at most twice +within a 3 second interval: + +```java + +@RateLimited(maxReconciliations = 2, within = 3, unit = TimeUnit.SECONDS) +@ControllerConfiguration +public class MyReconciler implements Reconciler { + +} +``` + +Thus, if a given resource was reconciled twice in one second, no further reconciliation for this +resource will happen before two seconds have elapsed. Note that, since rate is limited on a +per-resource basis, other resources can still be reconciled at the same time, as long, of course, +that they stay within their own rate limits. + +## Optimizing Caches + +One of the ideas around the operator pattern is that all the relevant resources are cached, thus reconciliation is +usually very fast (especially if no resources are updated in the process) since the operator is then mostly working with +in-memory state. However for large clusters, caching huge amount of primary and secondary resources might consume lots +of memory. JOSDK provides ways to mitigate this issue and optimize the memory usage of controllers. While these features +are working and tested, we need feedback from real production usage. + +### Bounded Caches for Informers + +Limiting caches for informers - thus for Kubernetes resources - is supported by ensuring that resources are in the cache +for a limited time, via a cache eviction of least recently used resources. This means that when resources are created +and frequently reconciled, they stay "hot" in the cache. However, if, over time, a given resource "cools" down, i.e. it +becomes less and less used to the point that it might not be reconciled anymore, it will eventually get evicted from the +cache to free up memory. If such an evicted resource were to become reconciled again, the bounded cache implementation +would then fetch it from the API server and the "hot/cold" cycle would start anew. + +Since all resources need to be reconciled when a controller start, it is not practical to set a maximal cache size as +it's desirable that all resources be cached as soon as possible to make the initial reconciliation process on start as +fast and efficient as possible, avoiding undue load on the API server. It's therefore more interesting to gradually +evict cold resources than try to limit cache sizes. + +See usage of the related implementation using [Caffeine](https://github.com/ben-manes/caffeine) cache in integration +tests +for [primary resources](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/caffeine-bounded-cache-support/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/sample/AbstractTestReconciler.java). + +See +also [CaffeineBoundedItemStores](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/caffeine-bounded-cache-support/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedItemStores.java) +for more details. \ No newline at end of file diff --git a/docs/content/en/docs/documentation/features.md b/docs/content/en/docs/documentation/features.md new file mode 100644 index 0000000000..8c8909c8b2 --- /dev/null +++ b/docs/content/en/docs/documentation/features.md @@ -0,0 +1,55 @@ +--- +title: Other Features +weight: 57 +--- + +The Java Operator SDK (JOSDK) is a high-level framework and tooling suite for implementing Kubernetes operators. By default, features follow best practices in an opinionated way. However, configuration options and feature flags are available to fine-tune or disable these features. + +## Support for Well-Known Kubernetes Resources + +Controllers can be registered for standard Kubernetes resources (not just custom resources), such as `Ingress`, `Deployment`, and others. + +See the [integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deployment) for an example of reconciling deployments. + +```java +public class DeploymentReconciler + implements Reconciler, TestExecutionInfoProvider { + + @Override + public UpdateControl reconcile( + Deployment resource, Context context) { + // omitted code + } +} +``` + +## Leader Election + +Operators are typically deployed with a single active instance. However, you can deploy multiple instances where only one (the "leader") processes events. This is achieved through "leader election." + +While all instances run and start their event sources to populate caches, only the leader processes events. If the leader crashes, other instances are already warmed up and ready to take over when a new leader is elected. + +See sample configuration in the [E2E test](https://github.com/java-operator-sdk/java-operator-sdk/blob/8865302ac0346ee31f2d7b348997ec2913d5922b/sample-operators/leader-election/src/main/java/io/javaoperatorsdk/operator/sample/LeaderElectionTestOperator.java#L21-L23). + +## Automatic CRD Generation + +**Note:** This feature is provided by the [Fabric8 Kubernetes Client](https://github.com/fabric8io/kubernetes-client), not JOSDK itself. + +To automatically generate CRD manifests from your annotated Custom Resource classes, add this dependency to your project: + +```xml + + + io.fabric8 + crd-generator-apt + provided + +``` + +The CRD will be generated in `target/classes/META-INF/fabric8` (or `target/test-classes/META-INF/fabric8` for test scope) with the CRD name suffixed by the generated spec version. + +For example, a CR using the `java-operator-sdk.io` group with a `mycrs` plural form will result in these files: +- `mycrs.java-operator-sdk.io-v1.yml` +- `mycrs.java-operator-sdk.io-v1beta1.yml` + +**Note for Quarkus users:** If you're using the `quarkus-operator-sdk` extension, you don't need to add any extra dependency for CRD generation - the extension handles this automatically. diff --git a/docs/content/en/docs/documentation/observability.md b/docs/content/en/docs/documentation/observability.md new file mode 100644 index 0000000000..27a68086d5 --- /dev/null +++ b/docs/content/en/docs/documentation/observability.md @@ -0,0 +1,112 @@ +--- +title: Observability +weight: 55 +--- + +## Runtime Info + +[RuntimeInfo](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RuntimeInfo.java#L16-L16) +is used mainly to check the actual health of event sources. Based on this information it is easy to implement custom +liveness probes. + +[stopOnInformerErrorDuringStartup](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L168-L168) +setting, where this flag usually needs to be set to false, in order to control the exact liveness properties. + +See also an example implementation in the +[WebPage sample](https://github.com/java-operator-sdk/java-operator-sdk/blob/3e2e7c4c834ef1c409d636156b988125744ca911/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java#L38-L43) + +## Contextual Info for Logging with MDC + +Logging is enhanced with additional contextual information using +[MDC](http://www.slf4j.org/manual.html#mdc). The following attributes are available in most +parts of reconciliation logic and during the execution of the controller: + +| MDC Key | Value added from primary resource | +|:---------------------------|:----------------------------------| +| `resource.apiVersion` | `.apiVersion` | +| `resource.kind` | `.kind` | +| `resource.name` | `.metadata.name` | +| `resource.namespace` | `.metadata.namespace` | +| `resource.resourceVersion` | `.metadata.resourceVersion` | +| `resource.generation` | `.metadata.generation` | +| `resource.uid` | `.metadata.uid` | + +For more information about MDC see this [link](https://www.baeldung.com/mdc-in-log4j-2-logback). + +## Metrics + +JOSDK provides built-in support for metrics reporting on what is happening with your reconcilers in the form of +the `Metrics` interface which can be implemented to connect to your metrics provider of choice, JOSDK calling the +methods as it goes about reconciling resources. By default, a no-operation implementation is provided thus providing a +no-cost sane default. A [micrometer](https://micrometer.io)-based implementation is also provided. + +You can use a different implementation by overriding the default one provided by the default `ConfigurationService`, as +follows: + +```java +Metrics metrics; // initialize your metrics implementation +Operator operator = new Operator(client, o -> o.withMetrics(metrics)); +``` + +### Micrometer implementation + +The micrometer implementation is typically created using one of the provided factory methods which, depending on which +is used, will return either a ready to use instance or a builder allowing users to customized how the implementation +behaves, in particular when it comes to the granularity of collected metrics. It is, for example, possible to collect +metrics on a per-resource basis via tags that are associated with meters. This is the default, historical behavior but +this will change in a future version of JOSDK because this dramatically increases the cardinality of metrics, which +could lead to performance issues. + +To create a `MicrometerMetrics` implementation that behaves how it has historically behaved, you can just create an +instance via: + +```java +MeterRegistry registry; // initialize your registry implementation +Metrics metrics = new MicrometerMetrics(registry); +``` + +Note, however, that this constructor is deprecated and we encourage you to use the factory methods instead, which either +return a fully pre-configured instance or a builder object that will allow you to configure more easily how the instance +will behave. You can, for example, configure whether or not the implementation should collect metrics on a per-resource +basis, whether or not associated meters should be removed when a resource is deleted and how the clean-up is performed. +See the relevant classes documentation for more details. + +For example, the following will create a `MicrometerMetrics` instance configured to collect metrics on a per-resource +basis, deleting the associated meters after 5 seconds when a resource is deleted, using up to 2 threads to do so. + +```java +MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(registry) + .withCleanUpDelayInSeconds(5) + .withCleaningThreadNumber(2) + .build(); +``` + +### Operator SDK metrics + +The micrometer implementation records the following metrics: + +| Meter name | Type | Tag names | Description | +|-------------------------------------------------------------|----------------|-------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| +| operator.sdk.reconciliations.executions.`` | gauge | group, version, kind | Number of executions of the named reconciler | +| operator.sdk.reconciliations.queue.size.`` | gauge | group, version, kind | How many resources are queued to get reconciled by named reconciler | +| operator.sdk.``.size | gauge map size | | Gauge tracking the size of a specified map (currently unused but could be used to monitor caches size) | +| operator.sdk.events.received | counter | ``, event, action | Number of received Kubernetes events | +| operator.sdk.events.delete | counter | `` | Number of received Kubernetes delete events | +| operator.sdk.reconciliations.started | counter | ``, reconciliations.retries.last, reconciliations.retries.number | Number of started reconciliations per resource type | +| operator.sdk.reconciliations.failed | counter | ``, exception | Number of failed reconciliations per resource type | +| operator.sdk.reconciliations.success | counter | `` | Number of successful reconciliations per resource type | +| operator.sdk.controllers.execution.reconcile | timer | ``, controller | Time taken for reconciliations per controller | +| operator.sdk.controllers.execution.cleanup | timer | ``, controller | Time taken for cleanups per controller | +| operator.sdk.controllers.execution.reconcile.success | counter | controller, type | Number of successful reconciliations per controller | +| operator.sdk.controllers.execution.reconcile.failure | counter | controller, exception | Number of failed reconciliations per controller | +| operator.sdk.controllers.execution.cleanup.success | counter | controller, type | Number of successful cleanups per controller | +| operator.sdk.controllers.execution.cleanup.failure | counter | controller, exception | Number of failed cleanups per controller | + +As you can see all the recorded metrics start with the `operator.sdk` prefix. ``, in the table above, +refers to resource-specific metadata and depends on the considered metric and how the implementation is configured and +could be summed up as follows: `group?, version, kind, [name, namespace?], scope` where the tags in square +brackets (`[]`) won't be present when per-resource collection is disabled and tags followed by a question mark are +omitted if the associated value is empty. Of note, when in the context of controllers' execution metrics, these tag +names are prefixed with `resource.`. This prefix might be removed in a future version for greater consistency. + + diff --git a/docs/content/en/docs/documentation/reconciler.md b/docs/content/en/docs/documentation/reconciler.md new file mode 100644 index 0000000000..3ea09cf167 --- /dev/null +++ b/docs/content/en/docs/documentation/reconciler.md @@ -0,0 +1,212 @@ +--- +title: Implementing a reconciler +weight: 45 +--- + +## How Reconciliation Works + +The reconciliation process is event-driven and follows this flow: + +1. **Event Reception**: Events trigger reconciliation from: + - **Primary resources** (usually custom resources) when created, updated, or deleted + - **Secondary resources** through registered event sources + +2. **Reconciliation Execution**: Each reconciler handles a specific resource type and listens for events from the Kubernetes API server. When an event arrives, it triggers reconciliation unless one is already running for that resource. The framework ensures no concurrent reconciliation occurs for the same resource. + +3. **Post-Reconciliation Processing**: After reconciliation completes, the framework: + - Schedules a retry if an exception was thrown + - Schedules new reconciliation if events were received during execution + - Schedules a timer event if rescheduling was requested (`UpdateControl.rescheduleAfter(..)`) + - Finishes reconciliation if none of the above apply + +The SDK core implements an event-driven system where events trigger reconciliation requests. + +## Implementing Reconciler and Cleaner Interfaces + +To implement a reconciler, you must implement the [`Reconciler`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Reconciler.java) interface. + +A Kubernetes resource lifecycle has two phases depending on whether the resource is marked for deletion: + +**Normal Phase**: The framework calls the `reconcile` method for regular resource operations. + +**Deletion Phase**: If the resource is marked for deletion and your `Reconciler` implements the [`Cleaner`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Cleaner.java) interface, only the `cleanup` method is called. The framework automatically handles finalizers for you. + +If you need explicit cleanup logic, always use finalizers. See [Finalizer support](#finalizer-support) for details. + +### Using `UpdateControl` and `DeleteControl` + +These classes control the behavior after reconciliation completes. + +[`UpdateControl`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/UpdateControl.java) can instruct the framework to: +- Update the status sub-resource +- Reschedule reconciliation with a time delay + +```java + @Override + public UpdateControl reconcile( + EventSourceTestCustomResource resource, Context context) { + // omitted code + + return UpdateControl.patchStatus(resource).rescheduleAfter(10, TimeUnit.SECONDS); + } +``` + +without an update: + +```java + @Override + public UpdateControl reconcile( + EventSourceTestCustomResource resource, Context context) { + // omitted code + + return UpdateControl.noUpdate().rescheduleAfter(10, TimeUnit.SECONDS); + } +``` + +Note, though, that using `EventSources` is the preferred way of scheduling since the +reconciliation is triggered only when a resource is changed, not on a timely basis. + +At the end of the reconciliation, you typically update the status sub-resources. +It is also possible to update both the status and the resource with the `patchResourceAndStatus` method. In this case, +the resource is updated first followed by the status, using two separate requests to the Kubernetes API. + +From v5 `UpdateControl` only supports patching the resources, by default +using [Server Side Apply (SSA)](https://kubernetes.io/docs/reference/using-api/server-side-apply/). +It is important to understand how SSA works in Kubernetes. Mainly, resources applied using SSA +should contain only the fields identifying the resource and those the user is interested in (a 'fully specified intent' +in Kubernetes parlance), thus usually using a resource created from scratch, see +[sample](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa). +To contrast, see the same sample, this time [without SSA](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAReconciler.java). + +Non-SSA based patch is still supported. +You can control whether or not to use SSA +using [`ConfigurationServcice.useSSAToPatchPrimaryResource()`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L385-L385) +and the related `ConfigurationServiceOverrider.withUseSSAToPatchPrimaryResource` method. +Related integration test can be +found [here](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa). + +Handling resources directly using the client, instead of delegating these updates operations to JOSDK by returning +an `UpdateControl` at the end of your reconciliation, should work appropriately. However, we do recommend to +use `UpdateControl` instead since JOSDK makes sure that the operations are handled properly, since there are subtleties +to be aware of. For example, if you are using a finalizer, JOSDK makes sure to include it in your fully specified intent +so that it is not unintentionally removed from the resource (which would happen if you omit it, since your controller is +the designated manager for that field and Kubernetes interprets the finalizer being gone from the specified intent as a +request for removal). + +[`DeleteControl`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DeleteControl.java) +typically instructs the framework to remove the finalizer after the dependent +resource are cleaned up in `cleanup` implementation. + +```java + +public DeleteControl cleanup(MyCustomResource customResource,Context context){ + // omitted code + + return DeleteControl.defaultDelete(); + } + +``` + +However, it is possible to instruct the SDK to not remove the finalizer, this allows to clean up +the resources in a more asynchronous way, mostly for cases when there is a long waiting period +after a delete operation is initiated. Note that in this case you might want to either schedule +a timed event to make sure `cleanup` is executed again or use event sources to get notified +about the state changes of the deleted resource. + +### Finalizer Support + +[Kubernetes finalizers](https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers/) +make sure that your `Reconciler` gets a chance to act before a resource is actually deleted +after it's been marked for deletion. Without finalizers, the resource would be deleted directly +by the Kubernetes server. + +Depending on your use case, you might or might not need to use finalizers. In particular, if +your operator doesn't need to clean any state that would not be automatically managed by the +Kubernetes cluster (e.g. external resources), you might not need to use finalizers. You should +use the +Kubernetes [garbage collection](https://kubernetes.io/docs/concepts/architecture/garbage-collection/#owners-dependents) +mechanism as much as possible by setting owner references for your secondary resources so that +the cluster can automatically delete them for you whenever the associated primary resource is +deleted. Note that setting owner references is the responsibility of the `Reconciler` +implementation, though [dependent resources](https://javaoperatorsdk.io/docs/documentation/dependent-resource-and-workflows/dependent-resources/) +make that process easier. + +If you do need to clean such a state, you need to use finalizers so that their +presence will prevent the Kubernetes server from deleting the resource before your operator is +ready to allow it. This allows for clean-up even if your operator was down when the resource was marked for deletion. + +JOSDK makes cleaning resources in this fashion easier by taking care of managing finalizers +automatically for you when needed. The only thing you need to do is let the SDK know that your +operator is interested in cleaning the state associated with your primary resources by having it +implement +the [`Cleaner

`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Cleaner.java) +interface. If your `Reconciler` doesn't implement the `Cleaner` interface, the SDK will consider +that you don't need to perform any clean-up when resources are deleted and will, therefore, not activate finalizer support. +In other words, finalizer support is added only if your `Reconciler` implements the `Cleaner` interface. + +The framework automatically adds finalizers as the first step, thus after a resource +is created but before the first reconciliation. The finalizer is added via a separate +Kubernetes API call. As a result of this update, the finalizer will then be present on the +resource. The reconciliation can then proceed as normal. + +The automatically added finalizer will also be removed after the `cleanup` is executed on +the reconciler. This behavior is customizable as explained +[above](#using-updatecontrol-and-deletecontrol) when we addressed the use of +`DeleteControl`. + +You can specify the name of the finalizer to use for your `Reconciler` using the +[`@ControllerConfiguration`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java) +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. + +### Making sure the primary resource is up to date for the next reconciliation + +It is typical to want to update the status subresource with the information that is available during the reconciliation. +This is sometimes referred to as the last observed state. When the primary resource is updated, though, the framework +does not cache the resource directly, relying instead on the propagation of the update to the underlying informer's +cache. It can, therefore, happen that, if other events trigger other reconciliations, before the informer cache gets +updated, your reconciler does not see the latest version of the primary resource. While this might not typically be a +problem in most cases, as caches eventually become consistent, depending on your reconciliation logic, you might still +require the latest status version possible, for example, if the status subresource is used to store allocated values. +See [Representing Allocated Values](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#representing-allocated-values) +from the Kubernetes docs for more details. + +The framework provides the +[`PrimaryUpdateAndCacheUtils`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java) utility class +to help with these use cases. + +This class' methods use internal caches in combination with update methods that leveraging +optimistic locking. If the update method fails on optimistic locking, it will retry +using a fresh resource from the server as base for modification. + +```java +@Override +public UpdateControl reconcile( + StatusPatchCacheCustomResource resource, Context context) { + + // omitted logic + + // update with SSA requires a fresh copy + var freshCopy = createFreshCopy(primary); + freshCopy.getStatus().setValue(statusWithState()); + + var updatedResource = PrimaryUpdateAndCacheUtils.ssaPatchStatusAndCacheResource(resource, freshCopy, context); + + // the resource was updated transparently via the utils, no further action is required via UpdateControl in this case + return UpdateControl.noUpdate(); + } +``` + +After the update `PrimaryUpdateAndCacheUtils.ssaPatchStatusAndCacheResource` puts the result of the update into an internal +cache and the framework will make sure that the next reconciliation contains the most recent version of the resource. +Note that it is not necessarily the same version returned as response from the update, it can be a newer version since other parties +can do additional updates meanwhile. However, unless it has been explicitly modified, that +resource will contain the up-to-date status. + +Note that you can also perform additional updates after the `PrimaryUpdateAndCacheUtils.*PatchStatusAndCacheResource` is +called, either by calling any of the `PrimeUpdateAndCacheUtils` methods again or via `UpdateControl`. Using +`PrimaryUpdateAndCacheUtils` guarantees that the next reconciliation will see a resource state no older than the version +updated via `PrimaryUpdateAndCacheUtils`. + +See related integration test [here](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache). diff --git a/docs/content/en/docs/documentation/working-with-es-caches.md b/docs/content/en/docs/documentation/working-with-es-caches.md new file mode 100644 index 0000000000..bb1e140303 --- /dev/null +++ b/docs/content/en/docs/documentation/working-with-es-caches.md @@ -0,0 +1,218 @@ +--- +title: Working with EventSource caches +weight: 48 +--- + +As described in [Event sources and related topics](eventing.md), event sources serve as the backbone +for caching resources and triggering reconciliation for primary resources that are related +to these secondary resources. + +In the Kubernetes ecosystem, the component responsible for this is called an Informer. Without delving into +the details (there are plenty of excellent resources online about informers), informers +watch resources, cache them, and emit events when resources change. + +`EventSource` is a generalized concept that extends the Informer pattern to non-Kubernetes resources, +allowing you to cache external resources and trigger reconciliation when those resources change. + +## The InformerEventSource + +The underlying informer implementation comes from the Fabric8 client, called [DefaultSharedIndexInformer](https://github.com/fabric8io/kubernetes-client/blob/main/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/informers/impl/DefaultSharedIndexInformer.java). +[InformerEventSource](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java) +in Java Operator SDK wraps the Fabric8 client informers. +While this wrapper adds additional capabilities specifically required for controllers, this is the event +source that most likely will be used to deal with Kubernetes resources. + +These additional capabilities include: +- Maintaining an index that maps secondary resources in the informer cache to their related primary resources +- Setting up multiple informers for the same resource type when needed (for example, you need one informer per namespace if the informer is not watching the entire cluster) +- Dynamically adding and removing watched namespaces +- Other capabilities that are beyond the scope of this document + +### Associating Secondary Resources to Primary Resource + +Event sources need to trigger the appropriate reconciler, providing the correct primary resource, whenever one of their +handled secondary resources changes. It is thus core to an event source's role to identify which primary resource +(usually, your custom resource) is potentially impacted by that change. +The framework uses [`SecondaryToPrimaryMapper`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java) +for this purpose. For `InformerEventSources`, which target Kubernetes resources, this mapping is typically done using +either the owner reference or an annotation on the secondary resource. For external resources, other mechanisms need to +be used and there are also cases where the default mechanisms provided by the SDK do not work, even for Kubernetes +resources. + +However, once the event source has triggered a primary resource reconciliation, the associated reconciler needs to +access the secondary resources which changes caused the reconciliation. Indeed, the information from the secondary +resources might be needed during the reconciliation. For that purpose, +`InformerEventSource` maintains a reverse +index [PrimaryToSecondaryIndex](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java), +based on the result of the `SecondaryToPrimaryMapper`result. + +## Unified API for Related Resources + +To access all related resources for a primary resource, the framework provides an API to access the related +secondary resources using the `Set getSecondaryResources(Class expectedType)` method of the `Context` object +provided as part of the `reconcile` method. + +For `InformerEventSource`, this will leverage the associated `PrimaryToSecondaryIndex`. Resources are then retrieved +from the informer's cache. Note that since all those steps work on top of indexes, those operations are very fast, +usually O(1). + +While we've focused mostly on `InformerEventSource`, this concept can be extended to all `EventSources`, since +[`EventSource`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java#L93) +actually implements the `Set getSecondaryResources(P primary)` method that can be called from the `Context`. + +As there can be multiple event sources for the same resource types, things are a little more complex: the union of each +event source results is returned. + +## Getting Resources Directly from Event Sources + +Note that nothing prevents you from directly accessing resources in the cache without going through +`getSecondaryResources(...)`: + +```java +public class WebPageReconciler implements Reconciler { + + InformerEventSource configMapEventSource; + + @Override + public UpdateControl reconcile(WebPage webPage, Context context) { + // accessing resource directly from an event source + var mySecondaryResource = configMapEventSource.get(new ResourceID("name","namespace")); + // details omitted + } + + @Override + public List> prepareEventSources(EventSourceContext context) { + configMapEventSource = new InformerEventSource<>( + InformerEventSourceConfiguration.from(ConfigMap.class, WebPage.class) + .withLabelSelector(SELECTOR) + .build(), + context); + + return List.of(configMapEventSource); + } +} +``` + +## The Use Case for PrimaryToSecondaryMapper + +**TL;DR**: `PrimaryToSecondaryMapper` allows `InformerEventSource` to access secondary resources directly +instead of using the `PrimaryToSecondaryIndex`. When this mapper is configured, `InformerEventSource.getSecondaryResources(..)` +will call the mapper to retrieve the target secondary resources. This is typically required when the `SecondaryToPrimaryMapper` +uses informer caches to list the target resources. + +As discussed, we provide a unified API to access related resources using `Context.getSecondaryResources(...)`. +The term "Secondary" refers to resources that a reconciler needs to consider when properly reconciling a primary +resource. These resources encompass more than just "child" resources (resources created by a reconciler that +typically have an owner reference pointing to the primary custom resource). They also include +"related" resources (which may or may not be managed by Kubernetes) that serve as input for reconciliations. + +In some cases, the SDK needs additional information beyond what's readily available, particularly when +secondary resources lack owner references or any direct link to their associated primary resource. + +Consider this example: a `Job` primary resource can be assigned to run on a cluster, represented by a +`Cluster` resource. +Multiple jobs can run on the same cluster, so multiple `Job` resources can reference the same `Cluster` resource. However, +a `Cluster` resource shouldn't know about `Job` resources, as this information isn't part of what defines a cluster. +When a cluster changes, though, we might want to redirect associated jobs to other clusters. Our reconciler +therefore needs to determine which `Job` (primary) resources are associated with the changed `Cluster` (secondary) +resource. +See full +sample [here](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary). + +```java +InformerEventSourceConfiguration + .from(Cluster.class, Job.class) + .withSecondaryToPrimaryMapper(cluster -> + context.getPrimaryCache() + .list() + .filter(job -> job.getSpec().getClusterName().equals(cluster.getMetadata().getName())) + .map(ResourceID::fromResource) + .collect(Collectors.toSet())) +``` + +This configuration will trigger all related `Jobs` when the associated cluster changes and maintains the `PrimaryToSecondaryIndex`, +allowing us to use `getSecondaryResources` in the `Job` reconciler to access the cluster. +However, there's a potential issue: when a new `Job` is created, it doesn't automatically propagate +to the `PrimaryToSecondaryIndex` in the `Cluster`'s `InformerEventSource`. Re-indexing only occurs +when a `Cluster` event is received, which triggers all related `Jobs` again. +Until this re-indexing happens, you cannot use `getSecondaryResources` for the new `Job`, since it +won't be present in the reverse index. + +You can work around this by accessing the Cluster directly from the cache in the reconciler: + +```java + +@Override +public UpdateControl reconcile(Job resource, Context context) { + + clusterInformer.get(new ResourceID(job.getSpec().getClusterName(), job.getMetadata().getNamespace())); + + // omitted details +} +``` + +However, if you prefer to use the unified API (`context.getSecondaryResources()`), you need to add +a `PrimaryToSecondaryMapper`: + +```java +clusterInformer.withPrimaryToSecondaryMapper( job -> + Set.of(new ResourceID(job.getSpec().getClusterName(), job.getMetadata().getNamespace()))); +``` + +When using `PrimaryToSecondaryMapper`, the InformerEventSource bypasses the `PrimaryToSecondaryIndex` +and instead calls the mapper to retrieve resources based on its results. +In fact, when this mapper is configured, the `PrimaryToSecondaryIndex` isn't even initialized. + +### Using Informer Indexes to Improve Performance + +In the `SecondaryToPrimaryMapper` example above, we iterate through all resources in the cache: + +```java +context.getPrimaryCache().list().filter(job -> job.getSpec().getClusterName().equals(cluster.getMetadata().getName())) +``` + +This approach can be inefficient when dealing with a large number of primary (`Job`) resources. To improve performance, +you can create an index in the underlying Informer that indexes the target jobs for each cluster: + +```java + +@Override +public List> prepareEventSources(EventSourceContext context) { + + context.getPrimaryCache() + .addIndexer(JOB_CLUSTER_INDEX, + (job -> List.of(indexKey(job.getSpec().getClusterName(), job.getMetadata().getNamespace())))); + + // omitted details +} +``` + +where `indexKey` is a String that uniquely identifies a Cluster: + +```java +private String indexKey(String clusterName, String namespace) { + return clusterName + "#" + namespace; +} +``` + +With this index in place, you can retrieve the target resources very efficiently: + +```java + + InformerEventSource clusterInformer = + new InformerEventSource( + InformerEventSourceConfiguration.from(Cluster.class, Job.class) + .withSecondaryToPrimaryMapper( + cluster -> + context + .getPrimaryCache() + .byIndex( + JOB_CLUSTER_INDEX, + indexKey( + cluster.getMetadata().getName(), + cluster.getMetadata().getNamespace())) + .stream() + .map(ResourceID::fromResource) + .collect(Collectors.toSet())) + .withNamespacesInheritedFromController().build(), context); +``` diff --git a/docs/content/en/docs/faq/_index.md b/docs/content/en/docs/faq/_index.md new file mode 100644 index 0000000000..977a725b0d --- /dev/null +++ b/docs/content/en/docs/faq/_index.md @@ -0,0 +1,162 @@ +--- +title: FAQ +weight: 90 +--- + +## Events and Reconciliation + +### How can I access the events that triggered reconciliation? + +In v1.* versions, events were exposed to `Reconciler` (then called `ResourceController`). This included custom resource events (Create, Update) and events from Event Sources. After extensive discussions with golang controller-runtime developers, we decided to remove event access. + +**Why this change was made:** +- Events can be lost in distributed systems +- Best practice is to reconcile all resources on every execution +- Aligns with Kubernetes [level-based](https://cloud.redhat.com/blog/kubernetes-operators-best-practices) reconciliation approach + +**Recommendation**: Always reconcile all resources instead of relying on specific events. + +### Can I reschedule a reconciliation with a specific delay? + +Yes, you can reschedule reconciliation using [`UpdateControl`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/UpdateControl.java) and [`DeleteControl`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DeleteControl.java). + +**With status update:** +```java +@Override +public UpdateControl reconcile( + EventSourceTestCustomResource resource, Context context) { + // ... reconciliation logic + return UpdateControl.patchStatus(resource).rescheduleAfter(10, TimeUnit.SECONDS); +} +``` + +**Without an update:** +```java +@Override +public UpdateControl reconcile( + EventSourceTestCustomResource resource, Context context) { + // ... reconciliation logic + return UpdateControl.noUpdate().rescheduleAfter(10, TimeUnit.SECONDS); +} +``` + +**Note**: Consider using `EventSources` for smarter reconciliation triggering instead of time-based scheduling. + +### How can I make status updates trigger reconciliation? + +By default, the framework filters out events that don't increase the `generation` field of the primary resource's metadata. Since `generation` typically only increases when the `.spec` field changes, status-only changes won't trigger reconciliation. + +To change this behavior, set [`generationAwareEventProcessing`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java#L43) to `false`: + +```java +@ControllerConfiguration(generationAwareEventProcessing = false) +static class TestCustomReconciler implements Reconciler { + @Override + public UpdateControl reconcile( + TestCustomResource resource, Context context) { + // reconciliation logic + } +} +``` + +For secondary resources, every change should trigger reconciliation by default, except when you add explicit filters or use dependent resource implementations that filter out self-triggered changes. See [related docs](../documentation/dependent-resource-and-workflows/dependent-resources.md#caching-and-event-handling-in-kubernetesdependentresource). + +## Permissions and Access Control + +### How can I run an operator without cluster-scope rights? + +By default, JOSDK requires cluster-scope access to custom resources. Without these rights, you'll see startup errors like: + +```plain +io.fabric8.kubernetes.client.KubernetesClientException: Failure executing: GET at: https://kubernetes.local.svc/apis/mygroup/v1alpha1/mycr. Message: Forbidden! Configured service account doesn't have access. Service account may have been revoked. mycrs.mygroup is forbidden: User "system:serviceaccount:ns:sa" cannot list resource "mycrs" in API group "mygroup" at the cluster scope. +``` + +**Solution 1: Restrict to specific namespaces** + +Override watched namespaces using [Reconciler-level configuration](../configuration.md#reconciler-level-configuration): + +```java +Operator operator; +Reconciler reconciler; +// ... +operator.register(reconciler, configOverrider -> + configOverrider.settingNamespace("mynamespace")); +``` + +**Note**: You can also configure watched namespaces using the `@ControllerConfiguration` annotation. + +**Solution 2: Disable CRD validation** + +If you can't list CRDs at startup (required when `checkingCRDAndValidateLocalModel` is `true`), disable it using [Operator-level configuration](../configuration#operator-level-configuration): + +```java +Operator operator = new Operator(override -> override.checkingCRDAndValidateLocalModel(false)); +``` + +## State Management + +### Where should I store generated IDs for external resources? + +When managing external (non-Kubernetes) resources, they often have generated IDs that aren't simply addressable based on your custom resource spec. You need to store these IDs for subsequent reconciliations. + +**Storage Options:** +1. **Separate resource** (usually ConfigMap, Secret, or dedicated CustomResource) +2. **Custom resource status field** + +**Important considerations:** + +Both approaches require guaranteeing resources are cached for the next reconciliation. If you patch status at the end of reconciliation (`UpdateControl.patchStatus(...)`), the fresh resource isn't guaranteed to be available during the next reconciliation. Controllers typically cache updated status in memory to ensure availability. + +**Modern solution**: From version 5.1, use [this utility](../documentation/reconciler.md#making-sure-the-primary-resource-is-up-to-date-for-the-next-reconciliation) to ensure updated status is available for the next reconciliation. + +**Dependent Resources**: This feature supports [the first approach](../documentation/dependent-resource-and-workflows/dependent-resources.md#external-state-tracking-dependent-resources) natively. + +## Advanced Use Cases + +### How can I skip the reconciliation of a dependent resource? + +Skipping workflow reconciliation altogether is possible with the explicit invocation feature since v5. You can read more about this in [v5 release notes](https://javaoperatorsdk.io/blog/2025/01/06/version-5-released/#explicit-workflow-invocation). + +However, what if you want to avoid reconciling a single dependent resource based on some state? First, remember that the dependent resource won't be modified if the desired state and actual state match. Moreover, it's generally good practice to reconcile all your resources, with JOSDK taking care of only processing resources whose state doesn't match the desired one. + +However, in some corner cases (for example, if it's expensive to compute the desired state or compare it to the actual state), it's sometimes useful to skip the reconciliation of some resources but not all, if it's known that they don't need processing based on the status of the custom resource. + +A common mistake is to use `ReconcilePrecondition`. If the condition doesn't hold, it will delete the resources. This is by design (although the name might be misleading), but not what we want in this case. + +The correct approach is to override the matcher in the dependent resource: + +```java +public Result match(R actualResource, R desired, P primary, Context

context) { + if (alreadyIsCertainState(primary.getStatus())) { + return true; + } else { + return super.match(actual, desired, primary, context); + } +} +``` + +This ensures the dependent resource isn't updated if the primary resource is in a certain state. + +## Troubleshooting + +### How to fix SSL certificate issues with Rancher Desktop and k3d/k3s + +This is a common issue when using k3d and the fabric8 client tries to connect to the cluster: + +``` +Caused by: javax.net.ssl.SSLHandshakeException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target + at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:131) + at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:352) + at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:295) +``` + +**Cause**: The fabric8 kubernetes client doesn't handle elliptical curve encryption by default. + +**Solution**: Add the following dependency to your classpath: + +```xml + + org.bouncycastle + bcpkix-jdk15on + +``` diff --git a/docs/content/en/docs/getting-started/_index.md b/docs/content/en/docs/getting-started/_index.md new file mode 100644 index 0000000000..df8a4b77fe --- /dev/null +++ b/docs/content/en/docs/getting-started/_index.md @@ -0,0 +1,4 @@ +--- +title: Getting started +weight: 10 +--- \ No newline at end of file diff --git a/docs/content/en/docs/getting-started/bootstrap-and-samples.md b/docs/content/en/docs/getting-started/bootstrap-and-samples.md new file mode 100644 index 0000000000..d0d94f860d --- /dev/null +++ b/docs/content/en/docs/getting-started/bootstrap-and-samples.md @@ -0,0 +1,95 @@ +--- +title: Bootstrapping and samples +weight: 20 +--- + +## Creating a New Operator Project + +### Using the Maven Plugin + +The simplest way to start a new operator project is using the provided Maven plugin, which generates a complete project skeleton: + +```shell +mvn io.javaoperatorsdk:bootstrapper:[version]:create \ + -DprojectGroupId=org.acme \ + -DprojectArtifactId=getting-started +``` + +This command creates a new Maven project with: +- A basic operator implementation +- Maven configuration with required dependencies +- Generated [CustomResourceDefinition](https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/#customresourcedefinitions) (CRD) + +### Building Your Project + +Build the generated project with Maven: +```shell +mvn clean install +``` + +The build process automatically generates the CustomResourceDefinition YAML file that you'll need to apply to your Kubernetes cluster. + +## Exploring Sample Operators + +The [sample-operators](https://github.com/java-operator-sdk/java-operator-sdk/tree/master/sample-operators) directory contains real-world examples demonstrating different JOSDK features and patterns: + +### Available Samples + +**[webpage](https://github.com/java-operator-sdk/java-operator-sdk/tree/master/sample-operators/webpage)** +- **Purpose**: Creates NGINX webservers from Custom Resources containing HTML code +- **Key Features**: Multiple implementation approaches using both low-level APIs and higher-level abstractions +- **Good for**: Understanding basic operator concepts and API usage patterns + +**[mysql-schema](https://github.com/java-operator-sdk/java-operator-sdk/tree/master/sample-operators/mysql-schema)** +- **Purpose**: Manages database schemas in MySQL instances +- **Key Features**: Demonstrates managing non-Kubernetes resources (external systems) +- **Good for**: Learning how to integrate with external services and manage state outside Kubernetes + +**[tomcat](https://github.com/java-operator-sdk/java-operator-sdk/tree/master/sample-operators/tomcat)** +- **Purpose**: Manages Tomcat instances and web applications +- **Key Features**: Multiple controllers managing related custom resources +- **Good for**: Understanding complex operators with multiple resource types and relationships + +## Running the Samples + +### Prerequisites + +The easiest way to try samples is using a local Kubernetes cluster: +- [minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/) +- [kind](https://kind.sigs.k8s.io/) +- [Docker Desktop with Kubernetes](https://docs.docker.com/desktop/kubernetes/) + +### Step-by-Step Instructions + +1. **Apply the CustomResourceDefinition**: + ```shell + kubectl apply -f target/classes/META-INF/fabric8/[resource-name]-v1.yml + ``` + +2. **Run the operator**: + ```shell + mvn exec:java -Dexec.mainClass="your.main.ClassName" + ``` + Or run your main class directly from your IDE. + +3. **Create custom resources**: + The operator will automatically detect and reconcile custom resources when you create them: + ```shell + kubectl apply -f examples/sample-resource.yaml + ``` + +### Detailed Examples + +For comprehensive setup instructions and examples, see: +- [MySQL Schema sample README](https://github.com/operator-framework/java-operator-sdk/blob/main/sample-operators/mysql-schema/README.md) +- Individual sample directories for specific setup requirements + +## Next Steps + +After exploring the samples: +1. Review the [patterns and best practices](../patterns-best-practices) guide +2. Learn about [implementing reconcilers](../../documentation/reconciler) +3. Explore [dependent resources and workflows](../../documentation/dependent-resource-and-workflows) for advanced use cases + + + diff --git a/docs/content/en/docs/getting-started/intro-to-operators.md b/docs/content/en/docs/getting-started/intro-to-operators.md new file mode 100644 index 0000000000..2879f00db9 --- /dev/null +++ b/docs/content/en/docs/getting-started/intro-to-operators.md @@ -0,0 +1,33 @@ +--- +title: Introduction to Kubernetes operators +weight: 15 +--- + +## What are Kubernetes Operators? + +Kubernetes operators are software extensions that manage both cluster and non-cluster resources on behalf of Kubernetes. The Java Operator SDK (JOSDK) makes it easy to implement Kubernetes operators in Java, with APIs designed to feel natural to Java developers and framework handling of common problems so you can focus on your business logic. + +## Why Use Java Operator SDK? + +JOSDK provides several key advantages: + +- **Java-native APIs** that feel familiar to Java developers +- **Automatic handling** of common operator challenges (caching, event handling, retries) +- **Production-ready features** like observability, metrics, and error handling +- **Simplified development** so you can focus on business logic instead of Kubernetes complexities + +## Learning Resources + +### Getting Started +- [Introduction to Kubernetes operators](https://blog.container-solutions.com/kubernetes-operators-explained) - Core concepts explained +- [Implementing Kubernetes Operators in Java](https://www.youtube.com/watch?v=CvftaV-xrB4) - Introduction talk +- [Kubernetes operator pattern documentation](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/) - Official Kubernetes docs + +### Deep Dives +- [Problems JOSDK solves](https://blog.container-solutions.com/a-deep-dive-into-the-java-operator-sdk) - Technical deep dive +- [Why Java operators make sense](https://blog.container-solutions.com/cloud-native-java-infrastructure-automation-with-kubernetes-operators) - Java in cloud-native infrastructure +- [Building a Kubernetes operator SDK for Java](https://csviri.medium.com/deep-dive-building-a-kubernetes-operator-sdk-for-java-developers-5008218822cb) - Framework design principles + +### Tutorials +- [Writing Kubernetes operators using JOSDK](https://developers.redhat.com/articles/2022/02/15/write-kubernetes-java-java-operator-sdk) - Step-by-step blog series + diff --git a/docs/content/en/docs/getting-started/patterns-best-practices.md b/docs/content/en/docs/getting-started/patterns-best-practices.md new file mode 100644 index 0000000000..f092d48971 --- /dev/null +++ b/docs/content/en/docs/getting-started/patterns-best-practices.md @@ -0,0 +1,118 @@ +--- +title: Patterns and best practices +weight: 25 +--- + +This document describes patterns and best practices for building and running operators, and how to implement them using the Java Operator SDK (JOSDK). + +See also best practices in the [Operator SDK](https://sdk.operatorframework.io/docs/best-practices/best-practices/). + +## Implementing a Reconciler + +### Always Reconcile All Resources + +Reconciliation can be triggered by events from multiple sources. It might be tempting to check the events and only reconcile the related resource or subset of resources that the controller manages. However, this is **considered an anti-pattern** for operators. + +**Why this is problematic:** +- Kubernetes' distributed nature makes it difficult to ensure all events are received +- If your operator misses some events and doesn't reconcile the complete state, it might operate with incorrect assumptions about the cluster state +- Always reconcile all resources, regardless of the triggering event + +JOSDK makes this efficient by providing smart caches to avoid unnecessary Kubernetes API server access and ensuring your reconciler is triggered only when needed. + +Since there's industry consensus on this topic, JOSDK no longer provides event access from `Reconciler` implementations starting with version 2. + +### Event Sources and Caching + +During reconciliation, best practice is to reconcile all dependent resources managed by the controller. This means comparing the desired state with the actual cluster state. + +**The Challenge**: Reading the actual state directly from the Kubernetes API Server every time would create significant load. + +**The Solution**: Create a watch for dependent resources and cache their latest state using the Informer pattern. In JOSDK, informers are wrapped into `EventSource` to integrate with the framework's eventing system via the `InformerEventSource` class. + +**How it works**: +- New events trigger reconciliation only when the resource is already cached +- Reconciler implementations compare desired state with cached observed state +- If a resource isn't in cache, it needs to be created +- If actual state doesn't match desired state, the resource needs updating + +### Idempotency + +Since all resources should be reconciled when your `Reconciler` is triggered, and reconciliations can be triggered multiple times for any given resource (especially with retry policies), it's crucial that `Reconciler` implementations be **idempotent**. + +**Idempotency means**: The same observed state should always result in exactly the same outcome. + +**Key implications**: +- Operators should generally operate in a stateless fashion +- Since operators usually manage declarative resources, ensuring idempotency is typically straightforward + +### Synchronous vs Asynchronous Resource Handling + +Sometimes your reconciliation logic needs to wait for resources to reach their desired state (e.g., waiting for a `Pod` to become ready). You can approach this either synchronously or asynchronously. + +#### Asynchronous Approach (Recommended) + +Exit the reconciliation logic as soon as the `Reconciler` determines it cannot complete at this point. This frees resources to process other events. + +**Requirements**: Set up adequate event sources to monitor state changes of all resources the operator waits for. When state changes occur, the `Reconciler` is triggered again and can finish processing. + +#### Synchronous Approach + +Periodically poll resources' state until they reach the desired state. If done within the `reconcile` method, this blocks the current thread for potentially long periods. + +**Recommendation**: Use the asynchronous approach for better resource utilization. + +## Why Use Automatic Retries? + +Automatic retries are enabled by default and configurable. While you can deactivate this feature, we advise against it. + +**Why retries are important**: +- **Transient network errors**: Common in Kubernetes' distributed environment, easily resolved with retries +- **Resource conflicts**: When multiple actors modify resources simultaneously, conflicts can be resolved by reconciling again +- **Transparency**: Automatic retries make error handling completely transparent when successful + +## Managing State + +Thanks to Kubernetes resources' declarative nature, operators dealing only with Kubernetes resources can operate statelessly. They don't need to maintain resource state information since it should be possible to rebuild the complete resource state from its representation. + +### When State Management Becomes Necessary + +This stateless approach typically breaks down when dealing with external resources. You might need to track external state or allocated +values for future reconciliations. There are multiple options: + + +1. Putting state in the primary resource's status sub-resource. This is a bit more complex that might seem at the first look. + Refer to the [documentation](../documentation/reconciler.md#making-sure-the-primary-resource-is-up-to-date-for-the-next-reconciliation) + for further details. + +2. Store state in separate resources designed for this purpose: +- Kubernetes Secret or ConfigMap +- Dedicated Custom Resource with validated structure + +## Handling Informer Errors and Cache Sync Timeouts + +You can [configure](https://github.com/java-operator-sdk/java-operator-sdk/blob/2cb616c4c4fd0094ee6e3a0ef2a0ea82173372bf/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L168-L168) whether the operator should stop when informer errors occur on startup. + +### Default Behavior +By default, if there's a startup error (e.g., the informer lacks permissions to list target resources for primary or secondary resources), the operator stops immediately. + +### Alternative Configuration +Set the flag to `false` to start the operator even when some informers fail to start. In this case: +- The operator continuously retries connection with exponential backoff +- This applies both to startup failures and runtime problems +- The operator only stops for fatal errors (currently when a resource cannot be deserialized) + +**Use case**: When watching multiple namespaces, it's better to start the operator so it can handle other namespaces while resolving permission issues in specific namespaces. + +### Cache Sync Timeout Impact +The `stopOnInformerErrorDuringStartup` setting affects [cache sync timeout](https://github.com/java-operator-sdk/java-operator-sdk/blob/114c4312c32b34688811df8dd7cea275878c9e73/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L177-L179) behavior: +- **If `true`**: Operator stops on cache sync timeout +- **If `false`**: After timeout, the controller starts reconciling resources even if some event source caches haven't synced yet + +## Graceful Shutdown + +You can provide sufficient time for the reconciler to process and complete ongoing events before shutting down. Simply set an appropriate duration value for `reconciliationTerminationTimeout` using `ConfigurationServiceOverrider`. + +```java +final var operator = new Operator(override -> override.withReconciliationTerminationTimeout(Duration.ofSeconds(5))); +``` diff --git a/docs/content/en/docs/glossary/_index.md b/docs/content/en/docs/glossary/_index.md new file mode 100644 index 0000000000..282a98d4df --- /dev/null +++ b/docs/content/en/docs/glossary/_index.md @@ -0,0 +1,12 @@ +--- +title: Glossary +weight: 100 +--- + +- **Primary Resource** - The resource representing the desired state that the controller works to achieve. While often a Custom Resource, it can also be a native Kubernetes resource (Deployment, ConfigMap, etc.). + +- **Secondary Resource** - Any resource the controller needs to manage to reach the desired state represented by the primary resource. These can be created, updated, deleted, or simply read depending on the use case. For example, the `Deployment` controller manages `ReplicaSet` instances to realize the state represented by the `Deployment`. Here, `Deployment` is the primary resource while `ReplicaSet` is a secondary resource. + +- **Dependent Resource** - A JOSDK feature that makes managing secondary resources easier. A dependent resource represents a secondary resource with associated reconciliation logic. + +- **Low-level API** - SDK APIs that don't use features beyond the core [`Reconciler`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Reconciler.java) interface (such as Dependent Resources or Workflows). See the [WebPage sample](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java). The same logic is also implemented using [Dependent Resource and Workflows](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageManagedDependentsReconciler.java). \ No newline at end of file diff --git a/docs/content/en/docs/migration/_index.md b/docs/content/en/docs/migration/_index.md new file mode 100644 index 0000000000..115adab35d --- /dev/null +++ b/docs/content/en/docs/migration/_index.md @@ -0,0 +1,5 @@ +--- +title: Migrations +weight: 150 +--- + diff --git a/docs/content/en/docs/migration/v2-migration.md b/docs/content/en/docs/migration/v2-migration.md new file mode 100644 index 0000000000..5b0ef31c45 --- /dev/null +++ b/docs/content/en/docs/migration/v2-migration.md @@ -0,0 +1,57 @@ +--- +title: Migrating from v1 to v2 +layout: docs +permalink: /docs/v2-migration +--- + +Version 2 of the framework introduces improvements, features and breaking changes for the APIs both +internal and user facing ones. The migration should be however trivial in most of the cases. For +detailed overview of all major issues until the release of +v`2.0.0` [see milestone on GitHub](https://github.com/java-operator-sdk/java-operator-sdk/milestone/1) +. For a summary and reasoning behind some naming changes +see [this issue](https://github.com/java-operator-sdk/java-operator-sdk/issues/655) + +## User Facing API Changes + +The following items are renamed and slightly changed: + +- [`ResourceController`](https://github.com/java-operator-sdk/java-operator-sdk/blob/v1/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/ResourceController.java) + interface is renamed + to [`Reconciler`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Reconciler.java) + . In addition, methods: + - `createOrUpdateResource` renamed to `reconcile` + - `deleteResource` renamed to `cleanup` +- Events are removed from + the [`Context`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java) + of `Reconciler` methods . The rationale behind this, is that there is a consensus now on the + pattern that the events should not be used to implement a reconciliation logic. +- The `init` method is extracted from `ResourceController` / `Reconciler` to a separate interface + called [EventSourceInitializer](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/EventSourceInitializer.java) + that `Reconciler` should implement in order to register event sources. The method has been renamed + to `prepareEventSources` and should now return a list of `EventSource` implementations that + the `Controller` will automatically register. See + also [sample](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java) + for usage. +- [`EventSourceManager`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java) + is now an internal class that users shouldn't need to interact with. +- [`@Controller`](https://github.com/java-operator-sdk/java-operator-sdk/blob/v1/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/Controller.java) + annotation renamed + to [`@ControllerConfiguration`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java) +- The metrics use `reconcile`, `cleanup` and `resource` labels instead of `createOrUpdate`, `delete` + and `cr`, respectively to match the new logic. + +### Event Sources + +- Addressing resources within event sources (and in the framework internally) is now changed + from `.metadata.uid` to a pair of `.metadata.name` and optional `.metadata.namespace` of resource. + Represented + by [`ResourceID.`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java) +- + +The [`Event`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/Event.java) +API is simplified. Now if an event source produces an event it needs to just produce an instance of +this class. + +- [`EventSource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java) + is refactored, but the changes are trivial. + diff --git a/docs/content/en/docs/migration/v3-1-migration.md b/docs/content/en/docs/migration/v3-1-migration.md new file mode 100644 index 0000000000..b4b42d9a5e --- /dev/null +++ b/docs/content/en/docs/migration/v3-1-migration.md @@ -0,0 +1,44 @@ +--- +title: Migrating from v3 to v3.1 +layout: docs +permalink: /docs/v3-1-migration +--- + +## ReconciliationMaxInterval Annotation has been renamed to MaxReconciliationInterval + +Associated methods on both the `ControllerConfiguration` class and annotation have also been +renamed accordingly. + +## Workflows Impact on Managed Dependent Resources Behavior + +Version 3.1 comes with a workflow engine that replaces the previous behavior of managed dependent +resources. +See [Workflows documentation](https://javaoperatorsdk.io/docs/documentation/dependent-resource-and-workflows/workflows/) for further details. +The primary impact after upgrade is a change of the order in which managed dependent resources +are reconciled. They are now reconciled in parallel with optional ordering defined using the +['depends_on'](https://github.com/java-operator-sdk/java-operator-sdk/blob/df44917ef81725c10bbcb772ab7b434d511b13b9/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/Dependent.java#L23-L23) +relation to define order between resources if needed. In v3, managed dependent resources were +implicitly reconciled in the order they were defined in. + +## Garbage Collected Kubernetes Dependent Resources + +In version 3 all Kubernetes Dependent Resource +implementing [`Deleter`](https://github.com/java-operator-sdk/java-operator-sdk/blob/bd063ccb7d55c110e96f24d2a10860d10aedfdb6/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/Deleter.java#L13-L13) +interface were meant to be also using owner references (thus garbage collected by Kubernetes). +In 3.1 there is a +dedicated [`GarbageCollected`](https://github.com/java-operator-sdk/java-operator-sdk/blob/bd063ccb7d55c110e96f24d2a10860d10aedfdb6/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/GarbageCollected.java#L28-L28) +interface to distinguish between Kubernetes resources meant to be garbage collected or explicitly +deleted. Please refer also to the `GarbageCollected` javadoc for more details on how this +impacts how owner references are managed. + +The supporting classes were also updated. Instead +of [`CRUKubernetesDependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/d99f65a736e9180e3f6de9a4239f80e47fc653fc/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/CRUKubernetesDependentResource.java) +there are two: + +- [`CRUDKubernetesDependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/bd063ccb7d55c110e96f24d2a10860d10aedfdb6/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/CRUDKubernetesDependentResource.java) + that is `GarbageCollected` +- [`CRUDNoGCKubernetesDependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/bd063ccb7d55c110e96f24d2a10860d10aedfdb6/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/CRUDNoGCKubernetesDependentResource.java) + what is `Deleter` but not `GarbageCollected` + +Use the one according to your use case. We anticipate that most people would want to use +`CRUDKubernetesDependentResource` whenever they have to work with Kubernetes dependent resources. \ No newline at end of file diff --git a/docs/content/en/docs/migration/v3-migration.md b/docs/content/en/docs/migration/v3-migration.md new file mode 100644 index 0000000000..462ab26f9f --- /dev/null +++ b/docs/content/en/docs/migration/v3-migration.md @@ -0,0 +1,37 @@ +--- +title: Migrating from v2 to v3 +layout: docs +permalink: /docs/v3-migration +--- + +Version 3 introduces some breaking changes to APIs, however the migration to these changes should be trivial. + +## Reconciler + +- [`Reconciler`](https://github.com/java-operator-sdk/java-operator-sdk/blob/67d8e25c26eb92392c6d2a9eb39ea6dddbbfafcc/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Reconciler.java#L16-L16) + can throw checked exception (not just runtime exception), and that also can be handled by `ErrorStatusHandler`. +- `cleanup` method is extracted from the `Reconciler` interface to a + separate [`Cleaner`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Cleaner.java) + interface. Finalizers only makes sense that the `Cleanup` is implemented, from + now finalizer is only added if the `Reconciler` implements this interface (or has managed dependent resources + implementing `Deleter` interface, see dependent resource docs). +- [`Context`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java#L9-L9) + object of `Reconciler` now takes the Primary resource as parametrized type: `Context`. +- [`ErrorStatusHandler`](https://github.com/java-operator-sdk/java-operator-sdk/blob/67d8e25c26eb92392c6d2a9eb39ea6dddbbfafcc/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ErrorStatusHandler.java) + result changed, it functionally has been extended to now prevent Exception to be retried and handles checked + exceptions as mentioned above. + + +## Event Sources + +- Event Sources are now registered with a name. But [utility method](https://github.com/java-operator-sdk/java-operator-sdk/blob/92bfafd8831e5fb9928663133f037f1bf4783e3e/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/EventSourceInitializer.java#L33-L33) + is available to make it easy to [migrate](https://github.com/java-operator-sdk/java-operator-sdk/blob/92bfafd8831e5fb9928663133f037f1bf4783e3e/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageStandaloneDependentsReconciler.java#L51-L52) + to a default name. +- [InformerEventSource](https://github.com/java-operator-sdk/java-operator-sdk/blob/92bfafd8831e5fb9928663133f037f1bf4783e3e/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java#L75-L75) + constructor changed to reflect additional functionality in a non backwards compatible way. All the configuration + options from the constructor where moved to [`InformerConfiguration`](https://github.com/java-operator-sdk/java-operator-sdk/blob/f6c6d568ea0a098e11beeeded20fe70f9c5bf692/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java) + . See sample usage in [`WebPageReconciler`](https://github.com/java-operator-sdk/java-operator-sdk/blob/f6c6d568ea0a098e11beeeded20fe70f9c5bf692/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java#L56-L59) + . +- `PrimaryResourcesRetriever` was renamed to `SecondaryToPrimaryMapper` +- `AssociatedSecondaryResourceIdentifier` was renamed to `PrimaryToSecondaryMapper` +- `getAssociatedResource` is now renamed to get `getSecondaryResource` in multiple places \ No newline at end of file diff --git a/docs/content/en/docs/migration/v4-3-migration.md b/docs/content/en/docs/migration/v4-3-migration.md new file mode 100644 index 0000000000..e9fd58c5f8 --- /dev/null +++ b/docs/content/en/docs/migration/v4-3-migration.md @@ -0,0 +1,47 @@ +--- +title: Migrating from v4.2 to v4.3 +layout: docs +permalink: /docs/v4-3-migration +--- + +## Condition API Change + +In Workflows the target of the condition was the managed resource itself, not the target dependent resource. +This changed, now the API contains the dependent resource. + +New API: + +```java +public interface Condition { + + boolean isMet(DependentResource dependentResource, P primary, Context

context); + +} +``` + +Former API: + +```java +public interface Condition { + + boolean isMet(P primary, R secondary, Context

context); + +} +``` + +Migration is trivial. Since the secondary resource can be accessed from the dependent resource. So to access the +secondary +resource just use `dependentResource.getSecondaryResource(primary,context)`. + +## HTTP client choice + +It is now possible to change the HTTP client used by the Fabric8 client to communicate with the Kubernetes API server. +By default, the SDK uses the historical default HTTP client which relies on Okhttp and there shouldn't be anything +needed to keep using this implementation. The `tomcat-operator` sample has been migrated to use the Vert.X based +implementation. You can see how to change the client by looking at +that [sample POM file](https://github.com/java-operator-sdk/java-operator-sdk/blob/d259fcd084f7e22032dfd0df3c7e64fe68850c1b/sample-operators/tomcat-operator/pom.xml#L37-L50): + +- You need to exclude the default implementation (in this case okhttp) from the `operator-framework` dependency +- You need to add the appropriate implementation dependency, `kubernetes-httpclient-vertx` in this case, HTTP client + implementations provided as part of the Fabric8 client all following the `kubernetes-httpclient-` + pattern for their artifact identifier. \ No newline at end of file diff --git a/docs/content/en/docs/migration/v4-4-migration.md b/docs/content/en/docs/migration/v4-4-migration.md new file mode 100644 index 0000000000..913c08b843 --- /dev/null +++ b/docs/content/en/docs/migration/v4-4-migration.md @@ -0,0 +1,90 @@ +--- +title: Migrating from v4.3 to v4.4 +layout: docs +permalink: /docs/v4-4-migration +--- + +## API changes + +### ConfigurationService + +We have simplified how to deal with the Kubernetes client. Previous versions provided direct +access to underlying aspects of the client's configuration or serialization mechanism. However, +the link between these aspects wasn't as explicit as it should have been. Moreover, the Fabric8 +client framework has also revised their serialization architecture in the 6.7 version (see [this +fabric8 pull request](https://github.com/fabric8io/kubernetes-client/pull/4662) for a discussion of +that change), moving from statically configured serialization to a per-client configuration +(though it's still possible to share serialization mechanism between client instances). As a +consequence, we made the following changes to the `ConfigurationService` API: + +- Replaced `getClientConfiguration` and `getObjectMapper` methods by a new `getKubernetesClient` + method: instead of providing the configuration and mapper, you now provide a client instance + configured according to your needs and the SDK will extract the needed information from it + +If you had previously configured a custom configuration or `ObjectMapper`, it is now recommended +that you do so when creating your client instance, as follows, usually using +`ConfigurationServiceOverrider.withKubernetesClient`: + +```java + +class Example { + + public static void main(String[] args) { + Config config; // your configuration + ObjectMapper mapper; // your mapper + final var operator = new Operator(overrider -> overrider.withKubernetesClient( + new KubernetesClientBuilder() + .withConfig(config) + .withKubernetesSerialization(new KubernetesSerialization(mapper, true)) + .build() + )); + } +} +``` + +Consequently, it is now recommended to get the client instance from the `ConfigurationService`. + +### Operator + +It is now recommended to configure your Operator instance by using a +`ConfigurationServiceOverrider` when creating it. This allows you to change the default +configuration values as needed. In particular, instead of passing a Kubernetes client instance +explicitly to the Operator constructor, it is now recommended to provide that value using +`ConfigurationServiceOverrider.withKubernetesClient` as shown above. + +## Using Server-Side Apply in Dependent Resources + +From this version by +default [Dependent Resources](https://javaoperatorsdk.io/docs/documentation/dependent-resource-and-workflows/dependent-resources/) use +[Server Side Apply (SSA)](https://kubernetes.io/docs/reference/using-api/server-side-apply/) to +create and +update Kubernetes resources. A +new [default matching](https://github.com/java-operator-sdk/java-operator-sdk/blob/2cc3bb7710adb8fca14767fbff8d93533dd05ef0/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java#L157-L157) +algorithm is provided for `KubernetesDependentResource` that is based on `managedFields` of SSA. For +details +see [SSABasedGenericKubernetesResourceMatcher](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java) + +Since those features are hard to completely test, we provided feature flags to revert to the +legacy behavior if needed, +see +in [ConfigurationService](https://github.com/java-operator-sdk/java-operator-sdk/blob/2cc3bb7710adb8fca14767fbff8d93533dd05ef0/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L332-L347) + +Note that it is possible to override the related methods/behavior on class level when extending +the `KubernetesDependentResource`. + +The SSA based create/update can be combined with the legacy matcher, simply override the `match` method +and use the [GenericKubernetesResourceMatcher](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java#L19-L19) +directly. See related [sample](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/ssalegacymatcher/ServiceDependentResource.java#L39-L44). + +### Migration from plain Update/Create to SSA Based Patch + +Migration to SSA might not be trivial based on the uses cases and the type of managed resources. +In general this is not a solved problem is Kubernetes. The Java Operator SDK Team tries to follow +the related issues, but in terms of implementation this is not something that the framework explicitly +supports. Thus, no code is added that tries to mitigate related issues. Users should thoroughly +test the migration, and even consider not to migrate in some cases (see feature flags above). + +See some related issues in [kubernetes](https://github.com/kubernetes/kubernetes/issues/118725) or +[here](https://github.com/keycloak/keycloak/pull). Please create related issue in JOSDK if any. + + diff --git a/docs/content/en/docs/migration/v4-5-migration.md b/docs/content/en/docs/migration/v4-5-migration.md new file mode 100644 index 0000000000..42e78d76dc --- /dev/null +++ b/docs/content/en/docs/migration/v4-5-migration.md @@ -0,0 +1,23 @@ +--- +title: Migrating from v4.4 to v4.5 +layout: docs +permalink: /docs/v4-5-migration +--- + +Version 4.5 introduces improvements related to event handling for Dependent Resources, more precisely the +[caching and event handling](https://javaoperatorsdk.io/docs/documentation/dependent-resource-and-workflows/dependent-resources/#caching-and-event-handling-in-kubernetesdependentresource) +features. As a result the Kubernetes resources managed using +[KubernetesDependentResource](https://github.com/java-operator-sdk/java-operator-sdk/blob/73b1d8db926a24502c3a70da34f6bcac4f66b4eb/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java#L72-L72) +or its subclasses, will add an annotation recording the resource's version whenever JOSDK updates or creates such +resources. This can be turned off using a +[feature flag](https://github.com/java-operator-sdk/java-operator-sdk/blob/73b1d8db926a24502c3a70da34f6bcac4f66b4eb/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L375-L375) +if causes some issues in your use case. + +Using this feature, JOSDK now tracks versions of cached resources. It also uses, by default, that information to prevent +unneeded reconciliations that could occur when, depending on the timing of operations, an outdated resource would happen +to be in the cache. This relies on the fact that versions (as recorded by the `metadata.resourceVersion` field) are +currently implemented as monotonically increasing integers (though they should be considered as opaque and their +interpretation discouraged). Note that, while this helps preventing unneeded reconciliations, things would eventually +reach consistency even in the absence of this feature. Also, if this interpreting of the resource versions causes +issues, you can turn the feature off using the +[following feature flag](https://github.com/java-operator-sdk/java-operator-sdk/blob/73b1d8db926a24502c3a70da34f6bcac4f66b4eb/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java#L390-L390). diff --git a/docs/content/en/docs/migration/v5-0-migration.md b/docs/content/en/docs/migration/v5-0-migration.md new file mode 100644 index 0000000000..31b3c2ca22 --- /dev/null +++ b/docs/content/en/docs/migration/v5-0-migration.md @@ -0,0 +1,6 @@ +--- +title: Migrating from v4.7 to v5.0 +description: Migrating from v4.7 to v5.0 +--- + +For migration to v5 see [this blogpost](../../blog/releases/v5-release.md). \ No newline at end of file diff --git a/docs/content/en/featured-background.jpg b/docs/content/en/featured-background.jpg new file mode 100644 index 0000000000..9be72c65cf Binary files /dev/null and b/docs/content/en/featured-background.jpg differ diff --git a/docs/content/en/search.md b/docs/content/en/search.md new file mode 100644 index 0000000000..394feea5fd --- /dev/null +++ b/docs/content/en/search.md @@ -0,0 +1,4 @@ +--- +title: Search Results +layout: search +--- diff --git a/docs/content/fileList.txt b/docs/content/fileList.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/docker-compose.yaml b/docs/docker-compose.yaml new file mode 100644 index 0000000000..9069dc0679 --- /dev/null +++ b/docs/docker-compose.yaml @@ -0,0 +1,13 @@ +version: "3.8" + +services: + + site: + image: docsy/docsy-example + build: + context: . + command: server + ports: + - "1313:1313" + volumes: + - .:/src diff --git a/docs/docsy.work b/docs/docsy.work new file mode 100644 index 0000000000..074dc2a129 --- /dev/null +++ b/docs/docsy.work @@ -0,0 +1,5 @@ +go 1.19 + +use . +use ../docsy/ // Local docsy clone resides in sibling folder to this project +// use ./themes/docsy/ // Local docsy clone resides in themes folder diff --git a/docs/docsy.work.sum b/docs/docsy.work.sum new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/go.mod b/docs/go.mod new file mode 100644 index 0000000000..65768398bb --- /dev/null +++ b/docs/go.mod @@ -0,0 +1,5 @@ +module github.com/google/docsy-example + +go 1.12 + +require github.com/google/docsy v0.11.0 // indirect diff --git a/docs/go.sum b/docs/go.sum new file mode 100644 index 0000000000..c7a05f45f1 --- /dev/null +++ b/docs/go.sum @@ -0,0 +1,12 @@ +github.com/FortAwesome/Font-Awesome v0.0.0-20240108205627-a1232e345536/go.mod h1:IUgezN/MFpCDIlFezw3L8j83oeiIuYoj28Miwr/KUYo= +github.com/FortAwesome/Font-Awesome v0.0.0-20240402185447-c0f460dca7f7/go.mod h1:IUgezN/MFpCDIlFezw3L8j83oeiIuYoj28Miwr/KUYo= +github.com/FortAwesome/Font-Awesome v0.0.0-20240716171331-37eff7fa00de/go.mod h1:IUgezN/MFpCDIlFezw3L8j83oeiIuYoj28Miwr/KUYo= +github.com/google/docsy v0.9.1 h1:+jqges1YCd+yHeuZ1BUvD8V8mEGVtPxULg5j/vaJ984= +github.com/google/docsy v0.9.1/go.mod h1:saOqKEUOn07Bc0orM/JdIF3VkOanHta9LU5Y53bwN2U= +github.com/google/docsy v0.10.0 h1:6tMDacPwAyRWNCfvsn/9qGOZDQ8b0aRzjRZvnZPY5dg= +github.com/google/docsy v0.10.0/go.mod h1:c0nIAqmRTOuJ01F85U/wJPQtc3Zj9N58Kea9bOT2AJc= +github.com/google/docsy v0.11.0 h1:QnV40cc28QwS++kP9qINtrIv4hlASruhC/K3FqkHAmM= +github.com/google/docsy v0.11.0/go.mod h1:hGGW0OjNuG5ZbH5JRtALY3yvN8ybbEP/v2iaK4bwOUI= +github.com/twbs/bootstrap v5.2.3+incompatible h1:lOmsJx587qfF7/gE7Vv4FxEofegyJlEACeVV+Mt7cgc= +github.com/twbs/bootstrap v5.2.3+incompatible/go.mod h1:fZTSrkpSf0/HkL0IIJzvVspTt1r9zuf7XlZau8kpcY0= +github.com/twbs/bootstrap v5.3.3+incompatible/go.mod h1:fZTSrkpSf0/HkL0IIJzvVspTt1r9zuf7XlZau8kpcY0= diff --git a/docs/hugo.toml b/docs/hugo.toml new file mode 100644 index 0000000000..435cad2451 --- /dev/null +++ b/docs/hugo.toml @@ -0,0 +1,199 @@ +baseURL = "/" +title = "Java Operator SDK" + +# Language settings +contentDir = "content/en" +defaultContentLanguage = "en" +defaultContentLanguageInSubdir = false +# Useful when translating. +enableMissingTranslationPlaceholders = true + +enableRobotsTXT = true + +# Will give values to .Lastmod etc. +enableGitInfo = true + +# Comment out to enable taxonomies in Docsy +# disableKinds = ["taxonomy", "taxonomyTerm"] + +# You can add your own taxonomies +[taxonomies] +tag = "tags" +category = "categories" + +[params.taxonomy] +# set taxonomyCloud = [] to hide taxonomy clouds +taxonomyCloud = ["tags", "categories"] + +# If used, must have same length as taxonomyCloud +taxonomyCloudTitle = ["Tag Cloud", "Categories"] + +# set taxonomyPageHeader = [] to hide taxonomies on the page headers +taxonomyPageHeader = ["tags", "categories"] + + +# Highlighting config +pygmentsCodeFences = true +pygmentsUseClasses = false +# Use the new Chroma Go highlighter in Hugo. +pygmentsUseClassic = false +#pygmentsOptions = "linenos=table" +# See https://help.farbox.com/pygments.html +pygmentsStyle = "tango" + +# Configure how URLs look like per section. +[permalinks] +blog = "/:section/:year/:month/:day/:slug/" + +# Image processing configuration. +[imaging] +resampleFilter = "CatmullRom" +quality = 75 +anchor = "smart" + +# Language configuration + +[languages] +[languages.en] +languageName ="English" +# Weight used for sorting. +weight = 1 +[languages.en.params] +title = "Java Operator SDK Docs" +description = "Documentation for Java Operator SDK" + +[markup] + [markup.goldmark] + [markup.goldmark.parser.attribute] + block = true + [markup.goldmark.renderer] + unsafe = true + [markup.highlight] + # See a complete list of available styles at https://xyproto.github.io/splash/docs/all.html + style = "tango" + # Uncomment if you want your chosen highlight style used for code blocks without a specified language + # guessSyntax = "true" + +# Everything below this are Site Params + +# Comment out if you don't want the "print entire section" link enabled. +[outputs] +section = ["HTML", "print", "RSS"] + +[params] +#privacy_policy = "https://policies.google.com/privacy" + +# First one is picked as the Twitter card image if not set on page. +# images = ["images/project-illustration.png"] + +# Menu title if your navbar has a versions selector to access old versions of your site. +# This menu appears only if you have at least one [params.versions] set. +version_menu = "Releases" + +# Flag used in the "version-banner" partial to decide whether to display a +# banner on every page indicating that this is an archived version of the docs. +# Set this flag to "true" if you want to display the banner. +archived_version = false + +# The version number for the version of the docs represented in this doc set. +# Used in the "version-banner" partial to display a version number for the +# current doc set. +version = "0.0" + +# A link to latest version of the docs. Used in the "version-banner" partial to +# point people to the main doc site. +url_latest_version = "https://javaoperatorsdk.io/" + +# Repository configuration (URLs for in-page links to opening issues and suggesting changes) +github_repo = "https://github.com/operator-framework/java-operator-sdk/" +# An optional link to a related project repo. For example, the sibling repository where your product code lives. +# github_project_repo = "https://github.com/operator-framework/java-operator-sdk" + +# Specify a value here if your content directory is not in your repo's root directory +github_subdir = "docs" + +# Uncomment this if your GitHub repo does not have "main" as the default branch, +# or specify a new value if you want to reference another branch in your GitHub links +github_branch= "main" + +# Google Custom Search Engine ID. Remove or comment out to disable search. +gcs_engine_id = "3062936b676d2428a" + +# Enable Lunr.js offline search +offlineSearch = false + +# Enable syntax highlighting and copy buttons on code blocks with Prism +prism_syntax_highlighting = false + +[params.copyright] + authors = "Java Operator SDK Developers" + from_year = 2019 + +# User interface configuration +[params.ui] +# Set to true to disable breadcrumb navigation. +breadcrumb_disable = false +# Set to false if you don't want to display a logo (/assets/icons/logo.svg) in the top navbar +navbar_logo = true +# Set to true if you don't want the top navbar to be translucent when over a `block/cover`, like on the homepage. +navbar_translucent_over_cover_disable = true +# Enable to show the side bar menu in its compact state. +sidebar_menu_compact = false +# Set to true to hide the sidebar search box (the top nav search box will still be displayed if search is enabled) +sidebar_search_disable = false + +# Adds a H2 section titled "Feedback" to the bottom of each doc. The responses are sent to Google Analytics as events. +# This feature depends on [services.googleAnalytics] and will be disabled if "services.googleAnalytics.id" is not set. +# If you want this feature, but occasionally need to remove the "Feedback" section from a single page, +# add "hide_feedback: true" to the page's front matter. +[params.ui.feedback] +enable = true +# The responses that the user sees after clicking "yes" (the page was helpful) or "no" (the page was not helpful). +yes = 'Glad to hear it! Please tell us how we can improve.' +no = 'Sorry to hear that. Please tell us how we can improve.' + +# Adds a reading time to the top of each doc. +# If you want this feature, but occasionally need to remove the Reading time from a single page, +# add "hide_readingtime: true" to the page's front matter +[params.ui.readingtime] +enable = false + +[params.links] +[[params.links.user]] + name ="BlueSky" + url = "https://bsky.app/profile/javaoperatorsdk.bsky.social" + icon = "fa-brands fa-bluesky" + desc = "Follow us on BlueSky to get the latest news!" +[[params.links.user]] +name = "Slack" +url = "https://kubernetes.slack.com/archives/CAW0GV7A5" +icon = "fab fa-slack" +desc = "Chat with other project developers on Kubernetes slack" +[[params.links.user]] +name = "Discord" +url = "https://discord.gg/DacEhAy" +icon = "fab fa-discord" +desc = "Chat with others on our dedicated Discord server" +#[[params.links.user]] +# name = "Stack Overflow" +# url = "https://example.org/stack" +# icon = "fab fa-stack-overflow" +# desc = "Practical questions and curated answers" +# Developer relevant links. These will show up on right side of footer and in the community page if you have one. +[[params.links.developer]] + name = "GitHub" + url = "https://github.com/operator-framework/java-operator-sdk" + icon = "fab fa-github" + desc = "Development takes place here!" + +# hugo module configuration + +[module] + # Uncomment the next line to build and serve using local docsy clone declared in the named Hugo workspace: + # workspace = "docsy.work" + [module.hugoVersion] + extended = true + min = "0.110.0" + [[module.imports]] + path = "github.com/google/docsy" + disable = false diff --git a/docs/layouts/404.html b/docs/layouts/404.html new file mode 100644 index 0000000000..1a9bd70440 --- /dev/null +++ b/docs/layouts/404.html @@ -0,0 +1,7 @@ +{{ define "main" -}} +

+

Not found

+

Oops! This page doesn't exist. Try going back to the home page.

+

You can learn how to make a 404 page like this in Custom 404 Pages.

+
+{{- end }} diff --git a/docs/layouts/_default/_markup/render-heading.html b/docs/layouts/_default/_markup/render-heading.html new file mode 100644 index 0000000000..7f8e97424d --- /dev/null +++ b/docs/layouts/_default/_markup/render-heading.html @@ -0,0 +1 @@ +{{ template "_default/_markup/td-render-heading.html" . }} diff --git a/docs/layouts/partials/scripts/mermaid.html b/docs/layouts/partials/scripts/mermaid.html new file mode 100644 index 0000000000..ac9290a10f --- /dev/null +++ b/docs/layouts/partials/scripts/mermaid.html @@ -0,0 +1,68 @@ + +{{ $version := .Site.Params.mermaid.version | default "latest" -}} + +{{ $cdnurl := printf "https://cdn.jsdelivr.net/npm/mermaid@%s/dist/mermaid.esm.min.mjs" $version -}} +{{ with try (resources.GetRemote $cdnurl) -}} +{{ with .Err -}} +{{ errorf "Could not retrieve mermaid script from CDN. Reason: %s." . -}} +{{ end -}} +{{ else -}} +{{ errorf "Invalid Mermaid version %s, could not retrieve this version from CDN." $version -}} +{{ end -}} + + \ No newline at end of file diff --git a/docs/netlify.toml b/docs/netlify.toml new file mode 100644 index 0000000000..e087db8274 --- /dev/null +++ b/docs/netlify.toml @@ -0,0 +1,12 @@ +# Hugo build configuration for Netlify +# (https://gohugo.io/hosting-and-deployment/hosting-on-netlify/#configure-hugo-version-in-netlify) + +[build] +command = "npm run build:preview" +publish = "public" + +[build.environment] +GO_VERSION = "1.21.4" + +[context.production] +command = "npm run build:production" diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000000..a5a57eaa4c --- /dev/null +++ b/docs/package.json @@ -0,0 +1,42 @@ +{ + "name": "java-operator-sdk", + "version": "0.10.0", + "version.next": "0.10.1-dev.0-unreleased", + "description": "Example site that uses Docsy theme for technical documentation.", + "repository": "github:google/docsy-example", + "homepage": "https://javaoperatorsdk.io/", + "author": "Java Operator SDK Devs", + "license": "Apache-2.0", + "bugs": "https://github.com/google/docsy-example/issues", + "spelling": "cSpell:ignore HTMLTEST precheck postbuild -", + "scripts": { + "_build": "npm run _hugo-dev --", + "_check:links": "echo IMPLEMENTATION PENDING for check-links; echo", + "_hugo": "hugo --cleanDestinationDir", + "_hugo-dev": "npm run _hugo -- -e dev -DFE", + "_local": "npx cross-env HUGO_MODULE_WORKSPACE=docsy.work", + "_serve": "npm run _hugo-dev -- --minify serve", + "build:preview": "npm run _hugo-dev -- --minify --baseURL \"${DEPLOY_PRIME_URL:-/}\"", + "build:production": "npm run _hugo -- --minify", + "build": "npm run _build -- ", + "check:links:all": "HTMLTEST_ARGS= npm run _check:links", + "check:links": "npm run _check:links", + "clean": "rm -Rf public/* resources", + "local": "npm run _local -- npm run", + "make:public": "git init -b main public", + "precheck:links:all": "npm run build", + "precheck:links": "npm run build", + "postbuild:preview": "npm run _check:links", + "postbuild:production": "npm run _check:links", + "serve": "npm run _serve", + "test": "npm run check:links", + "update:pkg:dep": "npm install --save-dev autoprefixer@latest postcss-cli@latest", + "update:pkg:hugo": "npm install --save-dev --save-exact hugo-extended@latest" + }, + "devDependencies": { + "autoprefixer": "^10.4.20", + "cross-env": "^7.0.3", + "hugo-extended": "0.125.4", + "postcss-cli": "^11.0.0" + } +} diff --git a/docs/static/favicons/favicon.ico b/docs/static/favicons/favicon.ico new file mode 100644 index 0000000000..de4cda6611 Binary files /dev/null and b/docs/static/favicons/favicon.ico differ diff --git a/docs/static/images/architecture.svg b/docs/static/images/architecture.svg new file mode 100644 index 0000000000..0f5a97da0d --- /dev/null +++ b/docs/static/images/architecture.svg @@ -0,0 +1,4 @@ + + + +
Operator
Operator
Controller
Controller
EventSourceManager
EventSourceManager
EventProcessor
EventProcessor
ReconcilerDispatcher
ReconcilerDispatcher
EventSource
EventSource
Propagate Event
Propagate Event
0..*
0..*
1..*
1..*
ControllerResourceEventSource
ControllerResourceEventSource
Propagate Event
Propagate Event
Reconciler
Reconciler
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/static/images/cncf_logo.png b/docs/static/images/cncf_logo.png new file mode 100644 index 0000000000..daa735c17b Binary files /dev/null and b/docs/static/images/cncf_logo.png differ diff --git a/docs/static/images/cncf_logo2.png b/docs/static/images/cncf_logo2.png new file mode 100644 index 0000000000..e1236b7e87 Binary files /dev/null and b/docs/static/images/cncf_logo2.png differ diff --git a/docs/static/images/cs-logo.svg b/docs/static/images/cs-logo.svg new file mode 100644 index 0000000000..ebf87d6c4b --- /dev/null +++ b/docs/static/images/cs-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/static/images/event-sources.png b/docs/static/images/event-sources.png new file mode 100644 index 0000000000..f37e1f72d9 Binary files /dev/null and b/docs/static/images/event-sources.png differ diff --git a/docs/static/images/full-logo-white.svg b/docs/static/images/full-logo-white.svg new file mode 100644 index 0000000000..5ed740df53 --- /dev/null +++ b/docs/static/images/full-logo-white.svg @@ -0,0 +1,20 @@ + + + + + + + + + + JAVA OPERATOR SDK + + + + + \ No newline at end of file diff --git a/docs/static/images/full_logo.png b/docs/static/images/full_logo.png new file mode 100644 index 0000000000..220129ae89 Binary files /dev/null and b/docs/static/images/full_logo.png differ diff --git a/docs/static/images/red-hat.webp b/docs/static/images/red-hat.webp new file mode 100644 index 0000000000..5b95e45a71 Binary files /dev/null and b/docs/static/images/red-hat.webp differ diff --git a/maven-settings.xml b/maven-settings.xml deleted file mode 100644 index 4b35e47225..0000000000 --- a/maven-settings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ossrh - ${env.SONATYPE_USERNAME} - ${env.SONATYPE_PASSWORD} - - - - - - ossrh - - true - - - gpg - - - - diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml new file mode 100644 index 0000000000..c66a2d339f --- /dev/null +++ b/micrometer-support/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + io.javaoperatorsdk + java-operator-sdk + 5.1.5-SNAPSHOT + + + micrometer-support + Operator SDK - Micrometer Support + + + + io.micrometer + micrometer-core + + + io.javaoperatorsdk + operator-framework-core + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + test + + + org.awaitility + awaitility + test + + + io.javaoperatorsdk + operator-framework-junit-5 + ${project.version} + test + + + io.fabric8 + kubernetes-httpclient-vertx + test + + + + diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java new file mode 100644 index 0000000000..26f149249b --- /dev/null +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java @@ -0,0 +1,448 @@ +package io.javaoperatorsdk.operator.monitoring.micrometer; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.CONTROLLER_NAME; + +public class MicrometerMetrics implements Metrics { + + private static final String PREFIX = "operator.sdk."; + private static final String RECONCILIATIONS = "reconciliations."; + private static final String RECONCILIATIONS_FAILED = RECONCILIATIONS + "failed"; + private static final String RECONCILIATIONS_SUCCESS = RECONCILIATIONS + "success"; + private static final String RECONCILIATIONS_RETRIES_LAST = RECONCILIATIONS + "retries.last"; + private static final String RECONCILIATIONS_RETRIES_NUMBER = RECONCILIATIONS + "retries.number"; + private static final String RECONCILIATIONS_STARTED = RECONCILIATIONS + "started"; + private static final String RECONCILIATIONS_EXECUTIONS = PREFIX + RECONCILIATIONS + "executions."; + private static final String RECONCILIATIONS_QUEUE_SIZE = PREFIX + RECONCILIATIONS + "queue.size."; + private static final String NAME = "name"; + private static final String NAMESPACE = "namespace"; + private static final String GROUP = "group"; + private static final String VERSION = "version"; + private static final String KIND = "kind"; + private static final String SCOPE = "scope"; + private static final String METADATA_PREFIX = "resource."; + private static final String CONTROLLERS_EXECUTION = "controllers.execution."; + private static final String CONTROLLER = "controller"; + private static final String SUCCESS_SUFFIX = ".success"; + private static final String FAILURE_SUFFIX = ".failure"; + private static final String TYPE = "type"; + private static final String EXCEPTION = "exception"; + private static final String EVENT = "event"; + private static final String ACTION = "action"; + private static final String EVENTS_RECEIVED = "events.received"; + private static final String EVENTS_DELETE = "events.delete"; + private static final String CLUSTER = "cluster"; + private static final String SIZE_SUFFIX = ".size"; + private final boolean collectPerResourceMetrics; + private final MeterRegistry registry; + private final Map gauges = new ConcurrentHashMap<>(); + private final Cleaner cleaner; + + /** + * Creates a MicrometerMetrics instance configured to not collect per-resource metrics, just + * aggregates per resource **type** + * + * @param registry the {@link MeterRegistry} instance to use for metrics recording + * @return a MicrometerMetrics instance configured to not collect per-resource metrics + */ + public static MicrometerMetrics withoutPerResourceMetrics(MeterRegistry registry) { + return new MicrometerMetrics(registry, Cleaner.NOOP, false); + } + + /** + * Creates a new builder to configure how the eventual MicrometerMetrics instance will behave. + * + * @param registry the {@link MeterRegistry} instance to use for metrics recording + * @return a MicrometerMetrics instance configured to not collect per-resource metrics + * @see MicrometerMetricsBuilder + */ + public static MicrometerMetricsBuilder newMicrometerMetricsBuilder(MeterRegistry registry) { + return new MicrometerMetricsBuilder(registry); + } + + /** + * Creates a new builder to configure how the eventual MicrometerMetrics instance will behave, + * pre-configuring it to collect metrics per resource. + * + * @param registry the {@link MeterRegistry} instance to use for metrics recording + * @return a MicrometerMetrics instance configured to not collect per-resource metrics + * @see PerResourceCollectingMicrometerMetricsBuilder + */ + public static PerResourceCollectingMicrometerMetricsBuilder + newPerResourceCollectingMicrometerMetricsBuilder(MeterRegistry registry) { + return new PerResourceCollectingMicrometerMetricsBuilder(registry); + } + + /** + * Creates a micrometer-based Metrics implementation that cleans up {@link Meter}s associated with + * deleted resources as specified by the (possibly {@code null}) provided {@link Cleaner} + * instance. + * + * @param registry the {@link MeterRegistry} instance to use for metrics recording + * @param cleaner the {@link Cleaner} to use + * @param collectingPerResourceMetrics whether to collect per resource metrics + */ + private MicrometerMetrics( + MeterRegistry registry, Cleaner cleaner, boolean collectingPerResourceMetrics) { + this.registry = registry; + this.cleaner = cleaner; + this.collectPerResourceMetrics = collectingPerResourceMetrics; + } + + @Override + public void controllerRegistered(Controller controller) { + final var configuration = controller.getConfiguration(); + final var name = configuration.getName(); + final var executingThreadsName = RECONCILIATIONS_EXECUTIONS + name; + final var resourceClass = configuration.getResourceClass(); + final var tags = new ArrayList(3); + addGVKTags(GroupVersionKind.gvkFor(resourceClass), tags, false); + AtomicInteger executingThreads = + registry.gauge(executingThreadsName, tags, new AtomicInteger(0)); + gauges.put(executingThreadsName, executingThreads); + + final var controllerQueueName = RECONCILIATIONS_QUEUE_SIZE + name; + AtomicInteger controllerQueueSize = + registry.gauge(controllerQueueName, tags, new AtomicInteger(0)); + gauges.put(controllerQueueName, controllerQueueSize); + } + + @Override + public T timeControllerExecution(ControllerExecution execution) { + final var name = execution.controllerName(); + final var execName = PREFIX + CONTROLLERS_EXECUTION + execution.name(); + final var resourceID = execution.resourceID(); + final var metadata = execution.metadata(); + final var tags = new ArrayList(16); + tags.add(Tag.of(CONTROLLER, name)); + addMetadataTags(resourceID, metadata, tags, true); + final var timer = + Timer.builder(execName) + .tags(tags) + .publishPercentiles(0.3, 0.5, 0.95) + .publishPercentileHistogram() + .register(registry); + try { + final var result = + timer.record( + () -> { + try { + return execution.execute(); + } catch (Exception e) { + throw new OperatorException(e); + } + }); + final var successType = execution.successTypeName(result); + registry.counter(execName + SUCCESS_SUFFIX, CONTROLLER, name, TYPE, successType).increment(); + return result; + } catch (Exception e) { + final var exception = e.getClass().getSimpleName(); + registry + .counter(execName + FAILURE_SUFFIX, CONTROLLER, name, EXCEPTION, exception) + .increment(); + throw e; + } + } + + @Override + public void receivedEvent(Event event, Map metadata) { + if (event instanceof ResourceEvent) { + incrementCounter( + event.getRelatedCustomResourceID(), + EVENTS_RECEIVED, + metadata, + Tag.of(EVENT, event.getClass().getSimpleName()), + Tag.of(ACTION, ((ResourceEvent) event).getAction().toString())); + } else { + incrementCounter( + event.getRelatedCustomResourceID(), + EVENTS_RECEIVED, + metadata, + Tag.of(EVENT, event.getClass().getSimpleName())); + } + } + + @Override + public void cleanupDoneFor(ResourceID resourceID, Map metadata) { + incrementCounter(resourceID, EVENTS_DELETE, metadata); + + cleaner.removeMetersFor(resourceID); + } + + @Override + public void reconcileCustomResource( + HasMetadata resource, RetryInfo retryInfoNullable, Map metadata) { + Optional retryInfo = Optional.ofNullable(retryInfoNullable); + incrementCounter( + ResourceID.fromResource(resource), + RECONCILIATIONS_STARTED, + metadata, + Tag.of( + RECONCILIATIONS_RETRIES_NUMBER, + String.valueOf(retryInfo.map(RetryInfo::getAttemptCount).orElse(0))), + Tag.of( + RECONCILIATIONS_RETRIES_LAST, + String.valueOf(retryInfo.map(RetryInfo::isLastAttempt).orElse(true)))); + + var controllerQueueSize = + gauges.get(RECONCILIATIONS_QUEUE_SIZE + metadata.get(CONTROLLER_NAME)); + controllerQueueSize.incrementAndGet(); + } + + @Override + public void finishedReconciliation(HasMetadata resource, Map metadata) { + incrementCounter(ResourceID.fromResource(resource), RECONCILIATIONS_SUCCESS, metadata); + } + + @Override + public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { + var reconcilerExecutions = + gauges.get(RECONCILIATIONS_EXECUTIONS + metadata.get(CONTROLLER_NAME)); + reconcilerExecutions.incrementAndGet(); + } + + @Override + public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { + var reconcilerExecutions = + gauges.get(RECONCILIATIONS_EXECUTIONS + metadata.get(CONTROLLER_NAME)); + reconcilerExecutions.decrementAndGet(); + + var controllerQueueSize = + gauges.get(RECONCILIATIONS_QUEUE_SIZE + metadata.get(CONTROLLER_NAME)); + controllerQueueSize.decrementAndGet(); + } + + @Override + public void failedReconciliation( + HasMetadata resource, Exception exception, Map metadata) { + var cause = exception.getCause(); + if (cause == null) { + cause = exception; + } else if (cause instanceof RuntimeException) { + cause = cause.getCause() != null ? cause.getCause() : cause; + } + incrementCounter( + ResourceID.fromResource(resource), + RECONCILIATIONS_FAILED, + metadata, + Tag.of(EXCEPTION, cause.getClass().getSimpleName())); + } + + @Override + public > T monitorSizeOf(T map, String name) { + return registry.gaugeMapSize(PREFIX + name + SIZE_SUFFIX, Collections.emptyList(), map); + } + + private void addMetadataTags( + ResourceID resourceID, Map metadata, List tags, boolean prefixed) { + if (collectPerResourceMetrics) { + addTag(NAME, resourceID.getName(), tags, prefixed); + addTagOmittingOnEmptyValue(NAMESPACE, resourceID.getNamespace().orElse(null), tags, prefixed); + } + addTag(SCOPE, getScope(resourceID), tags, prefixed); + final var gvk = (GroupVersionKind) metadata.get(Constants.RESOURCE_GVK_KEY); + if (gvk != null) { + addGVKTags(gvk, tags, prefixed); + } + } + + private static void addTag(String name, String value, List tags, boolean prefixed) { + tags.add(Tag.of(getPrefixedMetadataTag(name, prefixed), value)); + } + + private static void addTagOmittingOnEmptyValue( + String name, String value, List tags, boolean prefixed) { + if (value != null && !value.isBlank()) { + addTag(name, value, tags, prefixed); + } + } + + private static String getPrefixedMetadataTag(String tagName, boolean prefixed) { + return prefixed ? METADATA_PREFIX + tagName : tagName; + } + + private static String getScope(ResourceID resourceID) { + return resourceID.getNamespace().isPresent() ? NAMESPACE : CLUSTER; + } + + private static void addGVKTags(GroupVersionKind gvk, List tags, boolean prefixed) { + addTagOmittingOnEmptyValue(GROUP, gvk.getGroup(), tags, prefixed); + addTag(VERSION, gvk.getVersion(), tags, prefixed); + addTag(KIND, gvk.getKind(), tags, prefixed); + } + + private void incrementCounter( + ResourceID id, String counterName, Map metadata, Tag... additionalTags) { + final var additionalTagsNb = + additionalTags != null && additionalTags.length > 0 ? additionalTags.length : 0; + final var metadataNb = metadata != null ? metadata.size() : 0; + final var tags = new ArrayList(6 + additionalTagsNb + metadataNb); + addMetadataTags(id, metadata, tags, false); + if (additionalTagsNb > 0) { + tags.addAll(List.of(additionalTags)); + } + + final var counter = registry.counter(PREFIX + counterName, tags); + cleaner.recordAssociation(id, counter); + counter.increment(); + } + + protected Set recordedMeterIdsFor(ResourceID resourceID) { + return cleaner.recordedMeterIdsFor(resourceID); + } + + public static class PerResourceCollectingMicrometerMetricsBuilder + extends MicrometerMetricsBuilder { + + private int cleaningThreadsNumber; + private int cleanUpDelayInSeconds; + + private PerResourceCollectingMicrometerMetricsBuilder(MeterRegistry registry) { + super(registry); + } + + /** + * @param cleaningThreadsNumber the maximal number of threads that can be assigned to the + * removal of {@link Meter}s associated with deleted resources, defaults to 1 if not + * specified or if the provided number is lesser or equal to 0 + */ + public PerResourceCollectingMicrometerMetricsBuilder withCleaningThreadNumber( + int cleaningThreadsNumber) { + this.cleaningThreadsNumber = cleaningThreadsNumber <= 0 ? 1 : cleaningThreadsNumber; + return this; + } + + /** + * @param cleanUpDelayInSeconds the number of seconds to wait before {@link Meter}s are removed + * for deleted resources, defaults to 1 (meaning meters will be removed one second after the + * associated resource is deleted) if not specified or if the provided number is lesser than + * 0. Threading and the general interaction model of interacting with the API server means + * that it's not possible to ensure that meters are immediately deleted in all cases so a + * minimal delay of one second is always enforced + */ + public PerResourceCollectingMicrometerMetricsBuilder withCleanUpDelayInSeconds( + int cleanUpDelayInSeconds) { + this.cleanUpDelayInSeconds = Math.max(cleanUpDelayInSeconds, 1); + return this; + } + + @Override + public MicrometerMetrics build() { + final var cleaner = + new DelayedCleaner(registry, cleanUpDelayInSeconds, cleaningThreadsNumber); + return new MicrometerMetrics(registry, cleaner, true); + } + } + + public static class MicrometerMetricsBuilder { + protected final MeterRegistry registry; + private boolean collectingPerResourceMetrics = true; + + private MicrometerMetricsBuilder(MeterRegistry registry) { + this.registry = registry; + } + + /** Configures the instance to collect metrics on a per-resource basis. */ + @SuppressWarnings("unused") + public PerResourceCollectingMicrometerMetricsBuilder collectingMetricsPerResource() { + collectingPerResourceMetrics = true; + return new PerResourceCollectingMicrometerMetricsBuilder(registry); + } + + /** + * Configures the instance to only collect metrics per resource **type**, in an aggregate + * fashion, instead of per resource instance. + */ + @SuppressWarnings("unused") + public MicrometerMetricsBuilder notCollectingMetricsPerResource() { + collectingPerResourceMetrics = false; + return this; + } + + public MicrometerMetrics build() { + return new MicrometerMetrics(registry, Cleaner.NOOP, collectingPerResourceMetrics); + } + } + + interface Cleaner { + Cleaner NOOP = new Cleaner() {}; + + default void removeMetersFor(ResourceID resourceID) {} + + default void recordAssociation(ResourceID resourceID, Meter meter) {} + + default Set recordedMeterIdsFor(ResourceID resourceID) { + return Collections.emptySet(); + } + } + + static class DefaultCleaner implements Cleaner { + private final Map> metersPerResource = new ConcurrentHashMap<>(); + private final MeterRegistry registry; + + private DefaultCleaner(MeterRegistry registry) { + this.registry = registry; + } + + @Override + public void removeMetersFor(ResourceID resourceID) { + // remove each meter + final var toClean = metersPerResource.get(resourceID); + if (toClean != null) { + toClean.forEach(registry::remove); + } + // then clean-up local recording of associations + metersPerResource.remove(resourceID); + } + + @Override + public void recordAssociation(ResourceID resourceID, Meter meter) { + metersPerResource.computeIfAbsent(resourceID, id -> new HashSet<>()).add(meter.getId()); + } + + @Override + public Set recordedMeterIdsFor(ResourceID resourceID) { + return metersPerResource.get(resourceID); + } + } + + static class DelayedCleaner extends MicrometerMetrics.DefaultCleaner { + private final ScheduledExecutorService metersCleaner; + private final int cleanUpDelayInSeconds; + + private DelayedCleaner( + MeterRegistry registry, int cleanUpDelayInSeconds, int cleaningThreadsNumber) { + super(registry); + this.cleanUpDelayInSeconds = cleanUpDelayInSeconds; + this.metersCleaner = Executors.newScheduledThreadPool(cleaningThreadsNumber); + } + + @Override + public void removeMetersFor(ResourceID resourceID) { + // schedule deletion of meters associated with ResourceID + metersCleaner.schedule( + () -> super.removeMetersFor(resourceID), cleanUpDelayInSeconds, TimeUnit.SECONDS); + } + } +} diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/AbstractMicrometerMetricsTestFixture.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/AbstractMicrometerMetricsTestFixture.java new file mode 100644 index 0000000000..788253e5e8 --- /dev/null +++ b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/AbstractMicrometerMetricsTestFixture.java @@ -0,0 +1,104 @@ +package io.javaoperatorsdk.operator.monitoring.micrometer; + +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public abstract class AbstractMicrometerMetricsTestFixture { + + protected final TestSimpleMeterRegistry registry = new TestSimpleMeterRegistry(); + protected final MicrometerMetrics metrics = getMetrics(); + protected static final String testResourceName = "micrometer-metrics-cr"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withConfigurationService(overrider -> overrider.withMetrics(metrics)) + .withReconciler(new MetricsCleaningTestReconciler()) + .build(); + + protected abstract MicrometerMetrics getMetrics(); + + @Test + void properlyHandlesResourceDeletion() throws Exception { + var testResource = + new ConfigMapBuilder().withNewMetadata().withName(testResourceName).endMetadata().build(); + final var created = operator.create(testResource); + + // make sure the resource is created + await() + .until( + () -> + !operator + .get(ConfigMap.class, testResourceName) + .getMetadata() + .getFinalizers() + .isEmpty()); + + final var resourceID = ResourceID.fromResource(created); + final var meters = preDeleteChecks(resourceID); + + // delete the resource and wait for it to be deleted + operator.delete(testResource); + await().until(() -> operator.get(ConfigMap.class, testResourceName) == null); + + postDeleteChecks(resourceID, meters); + } + + protected Set preDeleteChecks(ResourceID resourceID) { + // check that we properly recorded meters associated with the resource + final var meters = metrics.recordedMeterIdsFor(resourceID); + // metrics are collected per resource + assertThat(registry.getMetersAsString()).contains(resourceID.getName()); + assertThat(meters).isNotNull(); + assertThat(meters).isNotEmpty(); + return meters; + } + + protected void postDeleteChecks(ResourceID resourceID, Set recordedMeters) + throws Exception {} + + @ControllerConfiguration + private static class MetricsCleaningTestReconciler + implements Reconciler, Cleaner { + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup(ConfigMap resource, Context context) { + return DeleteControl.defaultDelete(); + } + } + + static class TestSimpleMeterRegistry extends SimpleMeterRegistry { + private final Set removed = new HashSet<>(); + + @Override + public Meter remove(Meter.Id mappedId) { + final var removed = super.remove(mappedId); + this.removed.add(removed.getId()); + return removed; + } + + public Set getRemoved() { + return removed; + } + } +} diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DefaultBehaviorIT.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DefaultBehaviorIT.java new file mode 100644 index 0000000000..6f0388c49b --- /dev/null +++ b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DefaultBehaviorIT.java @@ -0,0 +1,31 @@ +package io.javaoperatorsdk.operator.monitoring.micrometer; + +import java.util.Collections; +import java.util.Set; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.micrometer.core.instrument.Meter; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DefaultBehaviorIT extends AbstractMicrometerMetricsTestFixture { + @Override + protected MicrometerMetrics getMetrics() { + return MicrometerMetrics.newMicrometerMetricsBuilder(registry).build(); + } + + @Override + protected Set preDeleteChecks(ResourceID resourceID) { + // no meter should be recorded because we're not tracking anything to be deleted later + assertThat(metrics.recordedMeterIdsFor(resourceID)).isEmpty(); + // metrics are collected per resource by default for now, this will change in a future release + assertThat(registry.getMetersAsString()).contains(resourceID.getName()); + return Collections.emptySet(); + } + + @Override + protected void postDeleteChecks(ResourceID resourceID, Set recordedMeters) { + // meters should be neither recorded, nor removed by default + assertThat(registry.getRemoved()).isEmpty(); + } +} diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DelayedMetricsCleaningOnDeleteIT.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DelayedMetricsCleaningOnDeleteIT.java new file mode 100644 index 0000000000..92929d5ddb --- /dev/null +++ b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DelayedMetricsCleaningOnDeleteIT.java @@ -0,0 +1,31 @@ +package io.javaoperatorsdk.operator.monitoring.micrometer; + +import java.time.Duration; +import java.util.Set; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.micrometer.core.instrument.Meter; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DelayedMetricsCleaningOnDeleteIT extends AbstractMicrometerMetricsTestFixture { + + private static final int testDelay = 1; + + @Override + protected MicrometerMetrics getMetrics() { + return MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(registry) + .withCleanUpDelayInSeconds(testDelay) + .withCleaningThreadNumber(2) + .build(); + } + + @Override + protected void postDeleteChecks(ResourceID resourceID, Set recordedMeters) + throws Exception { + // check that the meters are properly removed after the specified delay + Thread.sleep(Duration.ofSeconds(testDelay).toMillis()); + assertThat(registry.getRemoved()).isEqualTo(recordedMeters); + assertThat(metrics.recordedMeterIdsFor(resourceID)).isNull(); + } +} diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/NoPerResourceCollectionIT.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/NoPerResourceCollectionIT.java new file mode 100644 index 0000000000..ac35347697 --- /dev/null +++ b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/NoPerResourceCollectionIT.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.operator.monitoring.micrometer; + +import java.util.Collections; +import java.util.Set; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.micrometer.core.instrument.Meter; + +import static org.assertj.core.api.Assertions.assertThat; + +public class NoPerResourceCollectionIT extends AbstractMicrometerMetricsTestFixture { + @Override + protected MicrometerMetrics getMetrics() { + return MicrometerMetrics.withoutPerResourceMetrics(registry); + } + + @Override + protected Set preDeleteChecks(ResourceID resourceID) { + assertThat(metrics.recordedMeterIdsFor(resourceID)).isEmpty(); + assertThat(registry.getMetersAsString()).doesNotContain(resourceID.getName()); + return Collections.emptySet(); + } +} diff --git a/mvnw b/mvnw new file mode 100755 index 0000000000..5643201c7d --- /dev/null +++ b/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000000..23b7079a3d --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml new file mode 100644 index 0000000000..7770b05ab8 --- /dev/null +++ b/operator-framework-bom/pom.xml @@ -0,0 +1,185 @@ + + + 4.0.0 + + io.javaoperatorsdk + operator-framework-bom + 5.1.5-SNAPSHOT + pom + Operator SDK - Bill of Materials + Java SDK for implementing Kubernetes operators + https://github.com/operator-framework/java-operator-sdk + + + + Apache 2 License + https://www.apache.org/licenses/LICENSE-2.0.html + + + + + Attila Meszaros + csviri@gmail.com + + + Christophe Laprun + claprun@redhat.com + + + + + scm:git:git://github.com/java-operator-sdk/java-operator-sdk.git + scm:git:git@github.com/java-operator-sdk/java-operator-sdk.git + https://github.com/operator-framework/java-operator-sdk/tree/master + + + + 3.2.8 + 3.3.1 + 3.12.0 + 3.0.0 + 0.9.0 + + + + + + io.javaoperatorsdk + operator-framework-core + ${project.version} + + + io.javaoperatorsdk + operator-framework + ${project.version} + + + io.javaoperatorsdk + micrometer-support + ${project.version} + + + io.javaoperatorsdk + operator-framework-junit-5 + ${project.version} + + + + + + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless.version} + + + + pom.xml + ./**/pom.xml + + + false + + + + + true + + + java,javax,org,io,com,,\# + + + + + + + + apply + + compile + + + + + + + + + release + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*IT.java + **/*E2E.java + **/InformerRelatedBehaviorTest.java + + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-source-plugin + ${maven-source-plugin.version} + + + attach-sources + + jar + + verify + + + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + + sign + + verify + + + --pinentry-mode + loopback + + + + + + + org.sonatype.central + central-publishing-maven-plugin + ${central-publishing-maven-plugin.version} + true + + central + true + true + published + + + + + + + diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml new file mode 100644 index 0000000000..5b4281a1ec --- /dev/null +++ b/operator-framework-core/pom.xml @@ -0,0 +1,130 @@ + + + 4.0.0 + + io.javaoperatorsdk + java-operator-sdk + 5.1.5-SNAPSHOT + ../pom.xml + + + operator-framework-core + jar + Operator SDK - Framework - Core + Core framework for implementing Kubernetes operators + + + + io.github.java-diff-utils + java-diff-utils + + + io.fabric8 + kubernetes-client + + + io.fabric8 + kubernetes-httpclient-okhttp + + + + + org.slf4j + slf4j-api + + + org.apache.logging.log4j + log4j-slf4j2-impl + test + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + test + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + test + + + io.fabric8 + kubernetes-server-mock + test + + + org.awaitility + awaitility + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + io.github.git-commit-id + git-commit-id-maven-plugin + ${git-commit-id-maven-plugin.version} + + true + ${project.build.outputDirectory}/version.properties + + ^git.build.time$ + ^git.commit.id.(abbrev|full)$ + git.branch + + full + + + + get-the-git-infos + + revision + + initialize + + + + + org.codehaus.mojo + templating-maven-plugin + 3.0.0 + + + filtering-java-templates + + filter-sources + + + + + + + diff --git a/operator-framework-core/src/main/java-templates/io/javaoperatorsdk/operator/api/config/Versions.java b/operator-framework-core/src/main/java-templates/io/javaoperatorsdk/operator/api/config/Versions.java new file mode 100644 index 0000000000..8d67199510 --- /dev/null +++ b/operator-framework-core/src/main/java-templates/io/javaoperatorsdk/operator/api/config/Versions.java @@ -0,0 +1,10 @@ +package io.javaoperatorsdk.operator.api.config; + +public final class Versions { + + private Versions() {} + + protected static final String JOSDK = "${project.version}"; + protected static final String KUBERNETES_CLIENT = "${fabric8-client.version}"; + +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/AggregatedOperatorException.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/AggregatedOperatorException.java new file mode 100644 index 0000000000..611b92bf24 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/AggregatedOperatorException.java @@ -0,0 +1,41 @@ +package io.javaoperatorsdk.operator; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Collections; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +public class AggregatedOperatorException extends OperatorException { + + private final Map causes; + + public AggregatedOperatorException(String message, Map exceptions) { + super(message); + this.causes = + exceptions != null ? Collections.unmodifiableMap(exceptions) : Collections.emptyMap(); + } + + @SuppressWarnings("unused") + public Map getAggregatedExceptions() { + return causes; + } + + @Override + public String getMessage() { + return super.getMessage() + + " " + + causes.entrySet().stream() + .map(entry -> entry.getKey() + " -> " + exceptionDescription(entry)) + .collect(Collectors.joining("\n - ", "Details:\n - ", "")); + } + + private static String exceptionDescription(Entry entry) { + final var exception = entry.getValue(); + final var out = new StringWriter(2000); + final var stringWriter = new PrintWriter(out); + exception.printStackTrace(stringWriter); + return out.toString(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/BuilderUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/BuilderUtils.java new file mode 100644 index 0000000000..48e07e939d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/BuilderUtils.java @@ -0,0 +1,39 @@ +package io.javaoperatorsdk.operator; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +public final class BuilderUtils { + + // prevent instantiation of util class + private BuilderUtils() {} + + public static B newBuilder(Class builderType, T item) { + Class builderTargetType = builderTargetType(builderType); + try { + Constructor constructor = builderType.getDeclaredConstructor(builderTargetType); + return constructor.newInstance(item); + } catch (NoSuchMethodException + | SecurityException + | InstantiationException + | IllegalAccessException + | IllegalArgumentException + | InvocationTargetException e) { + throw new OperatorException( + "Failied to instantiate builder: " + builderType.getCanonicalName() + " using: " + item, + e); + } + } + + @SuppressWarnings("unchecked") + public static Class builderTargetType(Class builderType) { + try { + Method method = builderType.getDeclaredMethod("build"); + return (Class) method.getReturnType(); + } catch (NoSuchMethodException | SecurityException e) { + throw new OperatorException( + "Failied to determine target type for builder: " + builderType.getCanonicalName(), e); + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ControllerManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ControllerManager.java new file mode 100644 index 0000000000..2d6e4c91f0 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ControllerManager.java @@ -0,0 +1,100 @@ +package io.javaoperatorsdk.operator; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.api.config.ExecutorServiceManager; +import io.javaoperatorsdk.operator.processing.Controller; + +/** + * Not to be confused with the controller manager concept from Go's controller-runtime project. In + * JOSDK, the equivalent concept is {@link Operator}. + */ +class ControllerManager { + + public static final String CANNOT_REGISTER_MULTIPLE_CONTROLLERS_WITH_SAME_NAME_MESSAGE = + "Cannot register multiple controllers with same name: "; + private static final Logger log = LoggerFactory.getLogger(ControllerManager.class); + + @SuppressWarnings("rawtypes") + private final Map controllers = new HashMap<>(); + + private boolean started = false; + private final ExecutorServiceManager executorServiceManager; + + public ControllerManager(ExecutorServiceManager executorServiceManager) { + this.executorServiceManager = executorServiceManager; + } + + public synchronized void shouldStart() { + if (started) { + return; + } + if (controllers.isEmpty()) { + throw new OperatorException("No Controller exists. Exiting!"); + } + } + + public synchronized void start(boolean startEventProcessor) { + executorServiceManager.boundedExecuteAndWaitForAllToComplete( + controllers().stream(), + c -> { + c.start(startEventProcessor); + return null; + }, + c -> "Controller Starter for: " + c.getConfiguration().getName()); + started = true; + } + + public synchronized void stop() { + executorServiceManager.boundedExecuteAndWaitForAllToComplete( + controllers().stream(), + c -> { + log.debug("closing {}", c); + c.stop(); + return null; + }, + c -> "Controller Stopper for: " + c.getConfiguration().getName()); + started = false; + } + + public synchronized void startEventProcessing() { + executorServiceManager.boundedExecuteAndWaitForAllToComplete( + controllers().stream(), + c -> { + c.startEventProcessing(); + return null; + }, + c -> "Event processor starter for: " + c.getConfiguration().getName()); + } + + @SuppressWarnings("rawtypes") + synchronized void add(Controller controller) { + final var configuration = controller.getConfiguration(); + final var name = configuration.getName(); + if (controllers.containsKey(name)) { + throw new OperatorException( + CANNOT_REGISTER_MULTIPLE_CONTROLLERS_WITH_SAME_NAME_MESSAGE + name); + } + controllers.put(name, controller); + } + + @SuppressWarnings("rawtypes") + synchronized Optional get(String name) { + return Optional.ofNullable(controllers.get(name)); + } + + @SuppressWarnings("rawtypes") + synchronized Collection controllers() { + return controllers.values(); + } + + synchronized int size() { + return controllers.size(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/CustomResourceUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/CustomResourceUtils.java new file mode 100644 index 0000000000..6ae222c1c3 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/CustomResourceUtils.java @@ -0,0 +1,41 @@ +package io.javaoperatorsdk.operator; + +import io.fabric8.kubernetes.api.model.Cluster; +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; + +public class CustomResourceUtils { + + private CustomResourceUtils() {} + + /** + * Applies internal validations that may not be handled by the fabric8 client. + * + * @param resClass Custom Resource to validate + * @param crd CRD for the Custom Resource + * @throws OperatorException when the Custom Resource has validation error + */ + public static void assertCustomResource(Class resClass, CustomResourceDefinition crd) { + var namespaced = Namespaced.class.isAssignableFrom(resClass); + + if (!namespaced && Namespaced.class.getSimpleName().equals(crd.getSpec().getScope())) { + throw new OperatorException( + "Custom resource '" + + resClass.getName() + + "' must implement '" + + Namespaced.class.getName() + + "' since CRD '" + + crd.getMetadata().getName() + + "' is scoped as 'Namespaced'"); + } else if (namespaced && Cluster.class.getSimpleName().equals(crd.getSpec().getScope())) { + throw new OperatorException( + "Custom resource '" + + resClass.getName() + + "' must not implement '" + + Namespaced.class.getName() + + "' since CRD '" + + crd.getMetadata().getName() + + "' is scoped as 'Cluster'"); + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/LeaderElectionManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/LeaderElectionManager.java new file mode 100644 index 0000000000..72f23d628e --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/LeaderElectionManager.java @@ -0,0 +1,178 @@ +package io.javaoperatorsdk.operator; + +import java.util.Arrays; +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.authorization.v1.ResourceRule; +import io.fabric8.kubernetes.api.model.authorization.v1.SelfSubjectRulesReview; +import io.fabric8.kubernetes.api.model.authorization.v1.SelfSubjectRulesReviewSpecBuilder; +import io.fabric8.kubernetes.client.extended.leaderelection.LeaderCallbacks; +import io.fabric8.kubernetes.client.extended.leaderelection.LeaderElectionConfig; +import io.fabric8.kubernetes.client.extended.leaderelection.LeaderElector; +import io.fabric8.kubernetes.client.extended.leaderelection.LeaderElectorBuilder; +import io.fabric8.kubernetes.client.extended.leaderelection.resourcelock.LeaseLock; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration; + +public class LeaderElectionManager { + + private static final Logger log = LoggerFactory.getLogger(LeaderElectionManager.class); + + public static final String NO_PERMISSION_TO_LEASE_RESOURCE_MESSAGE = + "No permission to lease resource."; + public static final String UNIVERSAL_VALUE = "*"; + public static final String COORDINATION_GROUP = "coordination.k8s.io"; + public static final String LEASES_RESOURCE = "leases"; + + private LeaderElector leaderElector = null; + private final ControllerManager controllerManager; + private String identity; + private CompletableFuture leaderElectionFuture; + private final ConfigurationService configurationService; + private String leaseNamespace; + private String leaseName; + + LeaderElectionManager( + ControllerManager controllerManager, ConfigurationService configurationService) { + this.controllerManager = controllerManager; + this.configurationService = configurationService; + } + + public boolean isLeaderElectionEnabled() { + return configurationService.getLeaderElectionConfiguration().isPresent(); + } + + private void init(LeaderElectionConfiguration config) { + this.identity = identity(config); + leaseNamespace = + config + .getLeaseNamespace() + .orElseGet( + () -> configurationService.getKubernetesClient().getConfiguration().getNamespace()); + if (leaseNamespace == null) { + final var message = + "Lease namespace is not set and cannot be inferred. Leader election cannot continue."; + log.error(message); + throw new IllegalArgumentException(message); + } + leaseName = config.getLeaseName(); + final var lock = new LeaseLock(leaseNamespace, leaseName, identity); + leaderElector = + new LeaderElectorBuilder( + configurationService.getKubernetesClient(), + configurationService.getExecutorServiceManager().cachingExecutorService()) + .withConfig( + new LeaderElectionConfig( + lock, + config.getLeaseDuration(), + config.getRenewDeadline(), + config.getRetryPeriod(), + leaderCallbacks(config), + // this is required to be false to receive stop event in all cases, thus + // stopLeading + // is called always when leadership is lost/cancelled + false, + leaseName)) + .build(); + } + + private LeaderCallbacks leaderCallbacks(LeaderElectionConfiguration config) { + return new LeaderCallbacks( + () -> { + config.getLeaderCallbacks().ifPresent(LeaderCallbacks::onStartLeading); + LeaderElectionManager.this.startLeading(); + }, + () -> { + config.getLeaderCallbacks().ifPresent(LeaderCallbacks::onStopLeading); + LeaderElectionManager.this.stopLeading(); + }, + leader -> { + config.getLeaderCallbacks().ifPresent(cb -> cb.onNewLeader(leader)); + log.info("New leader with identity: {}", leader); + }); + } + + private void startLeading() { + controllerManager.startEventProcessing(); + } + + private void stopLeading() { + if (configurationService.getLeaderElectionConfiguration().orElseThrow().isExitOnStopLeading()) { + log.info("Stopped leading for identity: {}. Exiting.", identity); + // When leader stops leading the process ends immediately to prevent multiple reconciliations + // running parallel. + // Note that some reconciliations might run for a very long time. + System.exit(1); + } else { + log.info("Stopped leading, configured not to exit"); + } + } + + private String identity(LeaderElectionConfiguration config) { + var id = config.getIdentity().orElseGet(() -> System.getenv("HOSTNAME")); + if (id == null || id.isBlank()) { + id = UUID.randomUUID().toString(); + } + return id; + } + + public void start() { + if (isLeaderElectionEnabled()) { + init(configurationService.getLeaderElectionConfiguration().orElseThrow()); + checkLeaseAccess(); + leaderElectionFuture = leaderElector.start(); + } + } + + public void stop() { + if (leaderElectionFuture != null) { + leaderElectionFuture.cancel(false); + } + } + + private void checkLeaseAccess() { + var verbsRequired = Arrays.asList("create", "update", "get"); + SelfSubjectRulesReview review = new SelfSubjectRulesReview(); + review.setSpec(new SelfSubjectRulesReviewSpecBuilder().withNamespace(leaseNamespace).build()); + var reviewResult = configurationService.getKubernetesClient().resource(review).create(); + log.debug("SelfSubjectRulesReview result: {}", reviewResult); + var verbsAllowed = + reviewResult.getStatus().getResourceRules().stream() + .filter(rule -> matchesValue(rule.getApiGroups(), COORDINATION_GROUP)) + .filter(rule -> matchesValue(rule.getResources(), LEASES_RESOURCE)) + .filter( + rule -> + rule.getResourceNames().isEmpty() + || rule.getResourceNames().contains(leaseName)) + .map(ResourceRule::getVerbs) + .flatMap(Collection::stream) + .distinct() + .collect(Collectors.toList()); + if (verbsAllowed.contains(UNIVERSAL_VALUE) || verbsAllowed.containsAll(verbsRequired)) { + return; + } + + var missingVerbs = + verbsRequired.stream() + .filter(Predicate.not(verbsAllowed::contains)) + .collect(Collectors.toList()); + + throw new OperatorException( + NO_PERMISSION_TO_LEASE_RESOURCE_MESSAGE + + " in namespace: " + + leaseNamespace + + "; missing required verbs: " + + missingVerbs); + } + + private boolean matchesValue(Collection values, String match) { + return values.contains(match) || values.contains(UNIVERSAL_VALUE); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/MissingCRDException.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/MissingCRDException.java new file mode 100644 index 0000000000..caf310fb22 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/MissingCRDException.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator; + +public class MissingCRDException extends OperatorException { + private final String crdName; + private final String specVersion; + + public String getCrdName() { + return crdName; + } + + public String getSpecVersion() { + return specVersion; + } + + public MissingCRDException(String crdName, String specVersion) { + super(); + this.crdName = crdName; + this.specVersion = specVersion; + } + + public MissingCRDException(String crdName, String specVersion, String message) { + super(message); + this.crdName = crdName; + this.specVersion = specVersion; + } + + public MissingCRDException(String crdName, String specVersion, String message, Throwable cause) { + super(message, cause); + this.crdName = crdName; + this.specVersion = specVersion; + } +} 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 new file mode 100644 index 0000000000..f65f6ae022 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java @@ -0,0 +1,319 @@ +package io.javaoperatorsdk.operator; + +import java.time.Duration; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.Version; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.LifecycleAware; + +@SuppressWarnings("rawtypes") +public class Operator implements LifecycleAware { + private static final Logger log = LoggerFactory.getLogger(Operator.class); + + private ControllerManager controllerManager; + private LeaderElectionManager leaderElectionManager; + private ConfigurationService configurationService; + private volatile boolean started = false; + + public Operator() { + init(initConfigurationService(null, null), true); + } + + Operator(KubernetesClient kubernetesClient) { + init(initConfigurationService(kubernetesClient, null), false); + } + + /** + * Creates an Operator based on the configuration provided by the specified {@link + * ConfigurationService}. If you intend to use different values than the default, you use {@link + * Operator#Operator(Consumer)} instead to override the default with your intended setup. + * + * @param configurationService a {@link ConfigurationService} providing the configuration for the + * operator + */ + public Operator(ConfigurationService configurationService) { + init(configurationService, false); + } + + /** + * Creates an Operator overriding the default configuration with the values provided by the + * specified {@link ConfigurationServiceOverrider}. + * + * @param overrider a {@link ConfigurationServiceOverrider} consumer used to override the default + * {@link ConfigurationService} values + */ + public Operator(Consumer overrider) { + init(initConfigurationService(null, overrider), false); + } + + /** + * In a deferred initialization scenario, the default constructor will typically be called to + * create a proxy instance, usually to be replaced at some later time when the dependents (in this + * case the ConfigurationService instance) are available. In this situation, we want to make it + * possible to not perform the initialization steps directly so this implementation makes it + * possible to not crash when a null ConfigurationService is passed only if deferred + * initialization is allowed + * + * @param configurationService the potentially {@code null} {@link ConfigurationService} to use + * for this operator + * @param allowDeferredInit whether or not deferred initialization of the configuration service is + * allowed + * @throws IllegalStateException if the specified configuration service is {@code null} but + * deferred initialization is not allowed + */ + private void init(ConfigurationService configurationService, boolean allowDeferredInit) { + if (configurationService == null) { + if (!allowDeferredInit) { + throw new IllegalStateException( + "Deferred initialization of ConfigurationService is not allowed"); + } + } else { + this.configurationService = configurationService; + + final var executorServiceManager = configurationService.getExecutorServiceManager(); + controllerManager = new ControllerManager(executorServiceManager); + + leaderElectionManager = new LeaderElectionManager(controllerManager, configurationService); + } + } + + /** + * Overridable by subclasses to enable deferred configuration, useful to avoid unneeded processing + * in injection scenarios, typically returning {@code null} here instead of performing any + * configuration + * + * @param client a potentially {@code null} {@link KubernetesClient} to initialize the operator's + * {@link ConfigurationService} with + * @param overrider a potentially {@code null} {@link ConfigurationServiceOverrider} consumer to + * override the default {@link ConfigurationService} with + * @return a ready to use {@link ConfigurationService} using values provided by the specified + * overrides and kubernetes client, if provided or {@code null} in case deferred + * initialization is possible, in which case it is up to the extension to ensure that the + * {@link ConfigurationService} is properly set before the operator instance is used + */ + protected ConfigurationService initConfigurationService( + KubernetesClient client, Consumer overrider) { + // initialize the client if the user didn't provide one + if (client == null) { + var configurationService = ConfigurationService.newOverriddenConfigurationService(overrider); + client = configurationService.getKubernetesClient(); + } + + final var kubernetesClient = client; + + // override the configuration service to use the same client + if (overrider != null) { + overrider = overrider.andThen(o -> o.withKubernetesClient(kubernetesClient)); + } else { + overrider = o -> o.withKubernetesClient(kubernetesClient); + } + + return ConfigurationService.newOverriddenConfigurationService(overrider); + } + + /** + * Adds a shutdown hook that automatically calls {@link #stop()} when the app shuts down. Note + * that graceful shutdown is usually not needed, but some {@link Reconciler} implementations might + * require it. + * + *

Note that you might want to tune "terminationGracePeriodSeconds" for the Pod running the + * controller. + * + * @param gracefulShutdownTimeout timeout to wait for executor threads to complete actual + * reconciliations + */ + @SuppressWarnings("unused") + public void installShutdownHook(Duration gracefulShutdownTimeout) { + if (!leaderElectionManager.isLeaderElectionEnabled()) { + Runtime.getRuntime().addShutdownHook(new Thread(this::stop)); + } else { + log.warn("Leader election is on, shutdown hook will not be installed."); + } + } + + public KubernetesClient getKubernetesClient() { + return configurationService.getKubernetesClient(); + } + + /** + * Finishes the operator startup process. This is mostly used in injection-aware applications + * where there is no obvious entrypoint to the application which can trigger the injection process + * and start the cluster monitoring processes. + */ + public synchronized void start() { + try { + if (started) { + return; + } + controllerManager.shouldStart(); + final var version = configurationService.getVersion(); + log.info( + "Operator SDK {} (commit: {}) built on {} starting...", + version.getSdkVersion(), + version.getCommit(), + version.getBuiltTime()); + final var clientVersion = Version.clientVersion(); + log.info("Client version: {}", clientVersion); + + // need to create new thread pools if we're restarting because they've been shut down when we + // previously stopped + configurationService.getExecutorServiceManager().start(configurationService); + + // first start the controller manager before leader election, + // the leader election would start subsequently the processor if on + controllerManager.start(!leaderElectionManager.isLeaderElectionEnabled()); + leaderElectionManager.start(); + started = true; + } catch (Exception e) { + stop(); + throw new OperatorException("Error starting operator", e); + } + } + + @Override + public void stop() throws OperatorException { + Duration reconciliationTerminationTimeout = + configurationService.reconciliationTerminationTimeout(); + if (!started) { + return; + } + log.info( + "Operator SDK {} is shutting down...", configurationService.getVersion().getSdkVersion()); + controllerManager.stop(); + + configurationService.getExecutorServiceManager().stop(reconciliationTerminationTimeout); + leaderElectionManager.stop(); + if (configurationService.closeClientOnStop()) { + getKubernetesClient().close(); + } + + started = false; + } + + /** + * Add a registration requests for the specified reconciler with this operator. The effective + * registration of the reconciler is delayed till the operator is started. + * + * @param reconciler the reconciler to register + * @param

the {@code CustomResource} type associated with the reconciler + * @return registered controller + * @throws OperatorException if a problem occurred during the registration process + */ + public

RegisteredController

register(Reconciler

reconciler) + throws OperatorException { + final var controllerConfiguration = configurationService.getConfigurationFor(reconciler); + return register(reconciler, controllerConfiguration); + } + + /** + * Add a registration requests for the specified reconciler with this operator, overriding its + * default configuration by the specified one (usually created via {@link + * io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider#override(ControllerConfiguration)}, + * passing it the reconciler's original configuration. The effective registration of the + * reconciler is delayed till the operator is started. + * + * @param reconciler part of the reconciler to register + * @param configuration the configuration with which we want to register the reconciler + * @param

the {@code HasMetadata} type associated with the reconciler + * @return registered controller + * @throws OperatorException if a problem occurred during the registration process + */ + public

RegisteredController

register( + Reconciler

reconciler, ControllerConfiguration

configuration) throws OperatorException { + if (started) { + throw new OperatorException("Operator already started. Register all the controllers before."); + } + + if (configuration == null) { + throw new OperatorException( + "Cannot register reconciler with name " + + reconciler.getClass().getCanonicalName() + + " reconciler named " + + ReconcilerUtils.getNameFor(reconciler) + + " because its configuration cannot be found.\n" + + " Known reconcilers are: " + + configurationService.getKnownReconcilerNames()); + } + + final var controller = new Controller<>(reconciler, configuration, getKubernetesClient()); + + controllerManager.add(controller); + + final var informerConfig = configuration.getInformerConfig(); + final var watchedNS = + informerConfig.watchAllNamespaces() + ? "[all namespaces]" + : informerConfig.getEffectiveNamespaces(configuration); + + log.info( + "Registered reconciler: '{}' for resource: '{}' for namespace(s): {}", + configuration.getName(), + configuration.getResourceClass(), + watchedNS); + return controller; + } + + /** + * Method to register operator and facilitate configuration override. + * + * @param reconciler part of the reconciler to register + * @param configOverrider consumer to use to change config values + * @param

the {@code HasMetadata} type associated with the reconciler + * @return registered controller + */ + public

RegisteredController

register( + Reconciler

reconciler, Consumer> configOverrider) { + final var controllerConfiguration = configurationService.getConfigurationFor(reconciler); + var configToOverride = ControllerConfigurationOverrider.override(controllerConfiguration); + configOverrider.accept(configToOverride); + return register(reconciler, configToOverride.build()); + } + + public Optional getRegisteredController(String name) { + return controllerManager.get(name).map(RegisteredController.class::cast); + } + + public Set getRegisteredControllers() { + return new HashSet<>(controllerManager.controllers()); + } + + public int getRegisteredControllersNumber() { + return controllerManager.size(); + } + + public RuntimeInfo getRuntimeInfo() { + return new RuntimeInfo(this); + } + + boolean isStarted() { + return started; + } + + public ConfigurationService getConfigurationService() { + return configurationService; + } + + /** + * Make it possible for extensions to set the {@link ConfigurationService} after the operator has + * been initialized + * + * @param configurationService the {@link ConfigurationService} to use for this operator + */ + protected void setConfigurationService(ConfigurationService configurationService) { + init(configurationService, false); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/OperatorException.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/OperatorException.java new file mode 100644 index 0000000000..895d643fcb --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/OperatorException.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator; + +public class OperatorException extends RuntimeException { + + public OperatorException() {} + + public OperatorException(String message) { + super(message); + } + + public OperatorException(Throwable cause) { + super(cause); + } + + public OperatorException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java new file mode 100644 index 0000000000..a2d3d72e5f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java @@ -0,0 +1,262 @@ +package io.javaoperatorsdk.operator; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import io.fabric8.kubernetes.api.builder.Builder; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.client.KubernetesClientException; +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.Reconciler; + +@SuppressWarnings("rawtypes") +public class ReconcilerUtils { + + private static final String FINALIZER_NAME_SUFFIX = "/finalizer"; + protected static final String MISSING_GROUP_SUFFIX = ".javaoperatorsdk.io"; + private static final String GET_SPEC = "getSpec"; + private static final String SET_SPEC = "setSpec"; + private static final String SET_STATUS = "setStatus"; + private static final String GET_STATUS = "getStatus"; + private static final Pattern API_URI_PATTERN = + Pattern.compile(".*http(s?)://[^/]*/api(s?)/(\\S*).*"); // NOSONAR: input is controlled + + // prevent instantiation of util class + private ReconcilerUtils() {} + + public static boolean isFinalizerValid(String finalizer) { + return HasMetadata.validateFinalizer(finalizer); + } + + public static String getResourceTypeNameWithVersion(Class resourceClass) { + final var version = HasMetadata.getVersion(resourceClass); + return getResourceTypeName(resourceClass) + "/" + version; + } + + public static String getResourceTypeName(Class resourceClass) { + return HasMetadata.getFullResourceName(resourceClass); + } + + public static String getDefaultFinalizerName(Class resourceClass) { + return getDefaultFinalizerName(getResourceTypeName(resourceClass)); + } + + public static String getDefaultFinalizerName(String resourceName) { + // resource names for historic resources such as Pods are missing periods and therefore do not + // constitute valid domain names as mandated by Kubernetes so generate one that does + if (resourceName.indexOf('.') < 0) { + resourceName = resourceName + MISSING_GROUP_SUFFIX; + } + return resourceName + FINALIZER_NAME_SUFFIX; + } + + public static String getNameFor(Class reconcilerClass) { + // if the reconciler annotation has a name attribute, use it + final var annotation = reconcilerClass.getAnnotation(ControllerConfiguration.class); + if (annotation != null) { + final var name = annotation.name(); + if (!Constants.NO_VALUE_SET.equals(name)) { + return name; + } + } + // otherwise, use the lower-cased full class name + return getDefaultNameFor(reconcilerClass); + } + + public static void checkIfCanAddOwnerReference(HasMetadata owner, HasMetadata resource) { + if (owner instanceof GenericKubernetesResource + || resource instanceof GenericKubernetesResource) { + return; + } + if (owner instanceof Namespaced) { + if (!(resource instanceof Namespaced)) { + throw new OperatorException( + "Cannot add owner reference from a cluster scoped to a namespace scoped resource." + + resourcesIdentifierDescription(owner, resource)); + } else if (!Objects.equals( + owner.getMetadata().getNamespace(), resource.getMetadata().getNamespace())) { + throw new OperatorException( + "Cannot add owner reference between two resource in different namespaces." + + resourcesIdentifierDescription(owner, resource)); + } + } + } + + private static String resourcesIdentifierDescription(HasMetadata owner, HasMetadata resource) { + return " Owner name: " + + owner.getMetadata().getName() + + " Kind: " + + owner.getKind() + + ", Resource name: " + + resource.getMetadata().getName() + + " Kind: " + + resource.getKind(); + } + + public static String getNameFor(Reconciler reconciler) { + return getNameFor(reconciler.getClass()); + } + + public static String getDefaultNameFor(Reconciler reconciler) { + return getDefaultNameFor(reconciler.getClass()); + } + + public static String getDefaultNameFor(Class reconcilerClass) { + return getDefaultReconcilerName(reconcilerClass.getSimpleName()); + } + + public static String getDefaultReconcilerName(String reconcilerClassName) { + // if the name is fully qualified, extract the simple class name + final var lastDot = reconcilerClassName.lastIndexOf('.'); + if (lastDot > 0) { + reconcilerClassName = reconcilerClassName.substring(lastDot + 1); + } + return reconcilerClassName.toLowerCase(Locale.ROOT); + } + + public static boolean specsEqual(HasMetadata r1, HasMetadata r2) { + return getSpec(r1).equals(getSpec(r2)); + } + + // will be replaced with: https://github.com/fabric8io/kubernetes-client/issues/3816 + public static Object getSpec(HasMetadata resource) { + // optimize CustomResource case + if (resource instanceof CustomResource cr) { + return cr.getSpec(); + } + + return getSpecOrStatus(resource, GET_SPEC); + } + + public static Object getStatus(HasMetadata resource) { + // optimize CustomResource case + if (resource instanceof CustomResource cr) { + return cr.getStatus(); + } + return getSpecOrStatus(resource, GET_STATUS); + } + + private static Object getSpecOrStatus(HasMetadata resource, String getMethod) { + try { + Method getSpecMethod = resource.getClass().getMethod(getMethod); + return getSpecMethod.invoke(resource); + } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { + throw noMethodException(resource, e, getMethod); + } + } + + @SuppressWarnings("unchecked") + public static Object setSpec(HasMetadata resource, Object spec) { + // optimize CustomResource case + if (resource instanceof CustomResource cr) { + cr.setSpec(spec); + return null; + } + + return setSpecOrStatus(resource, spec, SET_SPEC); + } + + @SuppressWarnings("unchecked") + public static Object setStatus(HasMetadata resource, Object status) { + // optimize CustomResource case + if (resource instanceof CustomResource cr) { + cr.setStatus(status); + return null; + } + return setSpecOrStatus(resource, status, SET_STATUS); + } + + private static Object setSpecOrStatus( + HasMetadata resource, Object spec, String setterMethodName) { + try { + Class resourceClass = resource.getClass(); + + // if given spec is null, find the method just using its name + Method setMethod; + if (spec != null) { + setMethod = resourceClass.getMethod(setterMethodName, spec.getClass()); + } else { + setMethod = + Arrays.stream(resourceClass.getMethods()) + .filter(method -> setterMethodName.equals(method.getName())) + .findFirst() + .orElseThrow(() -> noMethodException(resource, null, setterMethodName)); + } + + return setMethod.invoke(resource, spec); + } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { + throw noMethodException(resource, e, setterMethodName); + } + } + + private static IllegalStateException noMethodException( + HasMetadata resource, ReflectiveOperationException e, String methodName) { + return new IllegalStateException( + "No method: " + methodName + " found on resource " + resource.getClass().getName(), e); + } + + public static T loadYaml(Class clazz, Class loader, String yaml) { + try (InputStream is = loader.getResourceAsStream(yaml)) { + if (Builder.class.isAssignableFrom(clazz)) { + return BuilderUtils.newBuilder( + clazz, Serialization.unmarshal(is, BuilderUtils.builderTargetType(clazz))); + } + return Serialization.unmarshal(is, clazz); + } catch (IOException ex) { + throw new IllegalStateException("Cannot find yaml on classpath: " + yaml); + } + } + + public static void handleKubernetesClientException(Exception e, String resourceTypeName) { + if (e instanceof MissingCRDException) { + throw ((MissingCRDException) e); + } + + if (e instanceof KubernetesClientException ke) { + // only throw MissingCRDException if the 404 error occurs on the target CRD + if (404 == ke.getCode() + && (resourceTypeName.equals(ke.getFullResourceName()) + || matchesResourceType(resourceTypeName, ke))) { + throw new MissingCRDException(resourceTypeName, ke.getVersion(), e.getMessage(), e); + } + } + } + + private static boolean matchesResourceType( + String resourceTypeName, KubernetesClientException exception) { + final var fullResourceName = exception.getFullResourceName(); + if (fullResourceName != null) { + return resourceTypeName.equals(fullResourceName); + } else { + // extract matching information from URI in the message if available + final var message = exception.getMessage(); + final var regex = API_URI_PATTERN.matcher(message); + if (regex.matches()) { + var group = regex.group(3); + if (group.endsWith(".")) { + group = group.substring(0, group.length() - 1); + } + final var segments = + Arrays.stream(group.split("/")).filter(Predicate.not(String::isEmpty)).toList(); + if (segments.size() != 3) { + return false; + } + final var targetResourceName = segments.get(2) + "." + segments.get(0); + return resourceTypeName.equals(targetResourceName); + } + } + return false; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RegisteredController.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RegisteredController.java new file mode 100644 index 0000000000..9db473260b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RegisteredController.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.NamespaceChangeable; +import io.javaoperatorsdk.operator.health.ControllerHealthInfo; + +public interface RegisteredController

extends NamespaceChangeable { + + ControllerConfiguration

getConfiguration(); + + ControllerHealthInfo getControllerHealthInfo(); +} 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 new file mode 100644 index 0000000000..0495131d79 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RuntimeInfo.java @@ -0,0 +1,104 @@ +package io.javaoperatorsdk.operator; + +import java.util.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.health.EventSourceHealthIndicator; +import io.javaoperatorsdk.operator.health.InformerWrappingEventSourceHealthIndicator; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; + +/** + * RuntimeInfo in general is available when operator is fully started. You can use "isStarted" to + * check that. + */ +@SuppressWarnings("rawtypes") +public class RuntimeInfo { + + private static final Logger log = LoggerFactory.getLogger(RuntimeInfo.class); + + private final Set registeredControllers; + private final Operator operator; + + public RuntimeInfo(Operator operator) { + this.registeredControllers = Collections.unmodifiableSet(operator.getRegisteredControllers()); + this.operator = operator; + } + + public boolean isStarted() { + return operator.isStarted(); + } + + @SuppressWarnings("unused") + public Set getRegisteredControllers() { + checkIfStarted(); + return registeredControllers; + } + + private void checkIfStarted() { + if (!isStarted()) { + log.warn( + "Operator not started yet while accessing runtime info, this might lead to an unreliable" + + " behavior"); + } + } + + public boolean allEventSourcesAreHealthy() { + checkIfStarted(); + return registeredControllers.stream() + .filter(rc -> !rc.getControllerHealthInfo().unhealthyEventSources().isEmpty()) + .findFirst() + .isEmpty(); + } + + /** + * @return Aggregated Map with controller related event sources. + */ + public Map> unhealthyEventSources() { + checkIfStarted(); + Map> res = new HashMap<>(); + for (var rc : registeredControllers) { + res.put( + rc.getConfiguration().getName(), rc.getControllerHealthInfo().unhealthyEventSources()); + } + return res; + } + + /** + * @return Aggregated Map with controller related event sources that wraps an informer. Thus, + * either a {@link ControllerEventSource} or an {@link + * io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource}. + */ + public Map> + unhealthyInformerWrappingEventSourceHealthIndicator() { + checkIfStarted(); + Map> res = new HashMap<>(); + for (var rc : registeredControllers) { + res.put( + rc.getConfiguration().getName(), + rc.getControllerHealthInfo().unhealthyInformerEventSourceHealthIndicators()); + } + return res; + } + + /** + * Retrieves the {@link RegisteredController} associated with the specified controller name or + * {@code null} if no such controller is registered. + * + * @param controllerName the name of the {@link RegisteredController} to retrieve + * @return the {@link RegisteredController} associated with the specified controller name or + * {@code null} if no such controller is registered + * @since 5.1.2 + */ + @SuppressWarnings({"unchecked", "unused"}) + public RegisteredController getRegisteredController( + String controllerName) { + checkIfStarted(); + return registeredControllers.stream() + .filter(rc -> rc.getConfiguration().getName().equals(controllerName)) + .findFirst() + .orElse(null); + } +} 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 new file mode 100644 index 0000000000..5abe6a7d03 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java @@ -0,0 +1,178 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; + +/** + * An abstract implementation of {@link ConfigurationService} meant to ease custom implementations + */ +@SuppressWarnings("rawtypes") +public class AbstractConfigurationService implements ConfigurationService { + private final Map configurations = new ConcurrentHashMap<>(); + private final Version version; + private KubernetesClient client; + private Cloner cloner; + private ExecutorServiceManager executorServiceManager; + + protected AbstractConfigurationService(Version version) { + this(version, null); + } + + protected AbstractConfigurationService(Version version, Cloner cloner) { + this(version, cloner, null, null); + } + + /** + * Creates a new {@link AbstractConfigurationService} with the specified parameters. + * + * @param client the {@link KubernetesClient} instance to use to connect to the cluster, if let + * {@code null}, the client will be lazily instantiated with the default configuration + * provided by {@link ConfigurationService#getKubernetesClient()} the first time {@link + * #getKubernetesClient()} is called + * @param version the version information + * @param cloner the {@link Cloner} to use, if {@code null} the default provided by {@link + * ConfigurationService#getResourceCloner()} will be used + * @param executorServiceManager the {@link ExecutorServiceManager} instance to be used, can be + * {@code null} to lazily initialize one by default when {@link #getExecutorServiceManager()} + * is called + */ + public AbstractConfigurationService( + Version version, + Cloner cloner, + ExecutorServiceManager executorServiceManager, + KubernetesClient client) { + this.version = version; + init(cloner, executorServiceManager, client); + } + + /** + * Subclasses can call this method to more easily initialize the {@link Cloner} and {@link + * ExecutorServiceManager} associated with this ConfigurationService implementation. This is + * useful in situations where the cloner depends on a mapper that might require additional + * configuration steps before it's ready to be used. + * + * @param cloner the {@link Cloner} instance to be used, if {@code null}, the default provided by + * {@link ConfigurationService#getResourceCloner()} will be used + * @param executorServiceManager the {@link ExecutorServiceManager} instance to be used, can be + * {@code null} to lazily initialize one by default when {@link #getExecutorServiceManager()} + * is called + * @param client the {@link KubernetesClient} instance to use to connect to the cluster, if let + * {@code null}, the client will be lazily instantiated with the default configuration + * provided by {@link ConfigurationService#getKubernetesClient()} the first time {@link + * #getKubernetesClient()} is called + */ + protected void init( + Cloner cloner, ExecutorServiceManager executorServiceManager, KubernetesClient client) { + this.client = client; + this.cloner = cloner != null ? cloner : ConfigurationService.super.getResourceCloner(); + this.executorServiceManager = executorServiceManager; + } + + protected void register(ControllerConfiguration config) { + put(config, true); + } + + protected void replace(ControllerConfiguration config) { + put(config, false); + } + + @SuppressWarnings("unchecked") + private void put( + ControllerConfiguration config, boolean failIfExisting) { + final var name = config.getName(); + if (failIfExisting) { + final var existing = configurations.get(name); + if (existing != null) { + throwExceptionOnNameCollision(config.getAssociatedReconcilerClassName(), existing); + } + } + configurations.put(name, config); + } + + protected void throwExceptionOnNameCollision( + String newReconcilerClassName, ControllerConfiguration existing) { + throw new IllegalArgumentException( + "Reconciler name '" + + existing.getName() + + "' is used by both " + + existing.getAssociatedReconcilerClassName() + + " and " + + newReconcilerClassName); + } + + @SuppressWarnings("unchecked") + @Override + public ControllerConfiguration getConfigurationFor( + Reconciler reconciler) { + final var key = keyFor(reconciler); + final var configuration = configurations.get(key); + if (configuration == null) { + logMissingReconcilerWarning(key, getReconcilersNameMessage()); + } + return configuration; + } + + protected void logMissingReconcilerWarning(String reconcilerKey, String reconcilersNameMessage) { + log.warn("Cannot find reconciler named '{}'. {}", reconcilerKey, reconcilersNameMessage); + } + + private String getReconcilersNameMessage() { + return "Known reconcilers: " + + getKnownReconcilerNames().stream().reduce((s, s2) -> s + ", " + s2).orElse("None") + + "."; + } + + protected String keyFor(Reconciler reconciler) { + return ReconcilerUtils.getNameFor(reconciler); + } + + @SuppressWarnings("unused") + protected ControllerConfiguration getFor(String reconcilerName) { + return configurations.get(reconcilerName); + } + + @SuppressWarnings("unused") + protected Stream controllerConfigurations() { + return configurations.values().stream(); + } + + @Override + public Set getKnownReconcilerNames() { + return configurations.keySet(); + } + + @Override + public Version getVersion() { + return version; + } + + @Override + public Cloner getResourceCloner() { + return cloner; + } + + @Override + public KubernetesClient getKubernetesClient() { + // lazy init to avoid needing initializing a client when not needed (in tests, in particular) + if (client == null) { + client = ConfigurationService.super.getKubernetesClient(); + } + return client; + } + + @Override + public ExecutorServiceManager getExecutorServiceManager() { + // lazy init to avoid initializing thread pools for nothing in an overriding scenario + if (executorServiceManager == null) { + executorServiceManager = ConfigurationService.super.getExecutorServiceManager(); + } + return executorServiceManager; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AnnotationConfigurable.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AnnotationConfigurable.java new file mode 100644 index 0000000000..9070eef441 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AnnotationConfigurable.java @@ -0,0 +1,7 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.lang.annotation.Annotation; + +public interface AnnotationConfigurable { + void initFrom(A configuration); +} 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 new file mode 100644 index 0000000000..891f199dbe --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java @@ -0,0 +1,344 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.lang.annotation.Annotation; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.config.Utils.Configurator; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceConfigurationResolver; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; +import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter; +import io.javaoperatorsdk.operator.processing.retry.Retry; + +import static io.javaoperatorsdk.operator.api.config.ControllerConfiguration.CONTROLLER_NAME_AS_FIELD_MANAGER; + +/** + * A default {@link ConfigurationService} implementation, resolving {@link Reconciler}s + * configuration when it has already been resolved before. If this behavior is not adequate, please + * use {@link AbstractConfigurationService} instead as a base for your {@code ConfigurationService} + * implementation. + */ +public class BaseConfigurationService extends AbstractConfigurationService { + + private static final String LOGGER_NAME = "Default ConfigurationService implementation"; + private static final Logger logger = LoggerFactory.getLogger(LOGGER_NAME); + private static final ResourceClassResolver DEFAULT_RESOLVER = new DefaultResourceClassResolver(); + + public BaseConfigurationService(Version version) { + this(version, null); + } + + public BaseConfigurationService(Version version, Cloner cloner) { + this(version, cloner, null); + } + + public BaseConfigurationService(Version version, Cloner cloner, KubernetesClient client) { + super(version, cloner, null, client); + } + + public BaseConfigurationService() { + this(Utils.VERSION); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static List dependentResources( + Workflow annotation, ControllerConfiguration controllerConfiguration) { + final var dependents = annotation.dependents(); + + if (dependents == null || dependents.length == 0) { + return Collections.emptyList(); + } + + final var specsMap = new LinkedHashMap(dependents.length); + for (Dependent dependent : dependents) { + final Class dependentType = dependent.type(); + + final var dependentName = getName(dependent.name(), dependentType); + var spec = specsMap.get(dependentName); + if (spec != null) { + throw new IllegalArgumentException( + "A DependentResource named '" + dependentName + "' already exists: " + spec); + } + + final var name = controllerConfiguration.getName(); + + var eventSourceName = dependent.useEventSourceWithName(); + eventSourceName = Constants.NO_VALUE_SET.equals(eventSourceName) ? null : eventSourceName; + final var context = Utils.contextFor(name, dependentType, null); + spec = + new DependentResourceSpec( + dependentType, + dependentName, + Set.of(dependent.dependsOn()), + Utils.instantiate(dependent.readyPostcondition(), Condition.class, context), + Utils.instantiate(dependent.reconcilePrecondition(), Condition.class, context), + Utils.instantiate(dependent.deletePostcondition(), Condition.class, context), + Utils.instantiate(dependent.activationCondition(), Condition.class, context), + eventSourceName); + specsMap.put(dependentName, spec); + + // extract potential configuration + DependentResourceConfigurationResolver.configureSpecFromConfigured( + spec, controllerConfiguration, dependentType); + + specsMap.put(dependentName, spec); + } + + return specsMap.values().stream().toList(); + } + + @SuppressWarnings("unchecked") + private static T valueOrDefaultFromAnnotation( + io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration controllerConfiguration, + Function mapper, + String defaultMethodName) { + try { + if (controllerConfiguration == null) { + return (T) + io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration.class + .getDeclaredMethod(defaultMethodName) + .getDefaultValue(); + } else { + return mapper.apply(controllerConfiguration); + } + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + @SuppressWarnings("rawtypes") + private static String getName(String name, Class dependentType) { + if (name.isBlank()) { + name = DependentResource.defaultNameFor(dependentType); + } + return name; + } + + @SuppressWarnings("unused") + private static Configurator configuratorFor( + Class instanceType, Class> reconcilerClass) { + return instance -> configureFromAnnotatedReconciler(instance, reconcilerClass); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void configureFromAnnotatedReconciler( + Object instance, Class> reconcilerClass) { + if (instance instanceof AnnotationConfigurable configurable) { + final Class configurationClass = + (Class) + Utils.getFirstTypeArgumentFromSuperClassOrInterface( + instance.getClass(), AnnotationConfigurable.class); + final var configAnnotation = reconcilerClass.getAnnotation(configurationClass); + if (configAnnotation != null) { + configurable.initFrom(configAnnotation); + } + } + } + + @Override + protected void logMissingReconcilerWarning(String reconcilerKey, String reconcilersNameMessage) { + if (!createIfNeeded()) { + logger.warn( + "Configuration for reconciler '{}' was not found. {}", + reconcilerKey, + reconcilersNameMessage); + } + } + + @SuppressWarnings("unused") + public String getLoggerName() { + return LOGGER_NAME; + } + + protected Logger getLogger() { + return logger; + } + + @Override + public ControllerConfiguration getConfigurationFor( + Reconciler reconciler) { + var config = super.getConfigurationFor(reconciler); + if (config == null) { + if (createIfNeeded()) { + // create the configuration on demand and register it + config = configFor(reconciler); + register(config); + getLogger() + .info( + "Created configuration for reconciler {} with name {}", + reconciler.getClass().getName(), + config.getName()); + } + } else { + // check that we don't have a reconciler name collision + final var newControllerClassName = reconciler.getClass().getCanonicalName(); + if (!config.getAssociatedReconcilerClassName().equals(newControllerClassName)) { + throwExceptionOnNameCollision(newControllerClassName, config); + } + } + return config; + } + + /** + * Override if a different class resolution is needed + * + * @return the custom {@link ResourceClassResolver} implementation to use + */ + protected ResourceClassResolver getResourceClassResolver() { + return DEFAULT_RESOLVER; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + protected

ControllerConfiguration

configFor(Reconciler

reconciler) { + final Class> reconcilerClass = + (Class>) reconciler.getClass(); + final var controllerAnnotation = + reconcilerClass.getAnnotation( + io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration.class); + + ResolvedControllerConfiguration

config = + controllerConfiguration(reconcilerClass, controllerAnnotation); + + final var workflowAnnotation = + reconcilerClass.getAnnotation(io.javaoperatorsdk.operator.api.reconciler.Workflow.class); + if (workflowAnnotation != null) { + final var specs = dependentResources(workflowAnnotation, config); + WorkflowSpec workflowSpec = + new WorkflowSpec() { + @Override + public List getDependentResourceSpecs() { + return specs; + } + + @Override + public boolean isExplicitInvocation() { + return workflowAnnotation.explicitInvocation(); + } + + @Override + public boolean handleExceptionsInReconciler() { + return workflowAnnotation.handleExceptionsInReconciler(); + } + }; + config.setWorkflowSpec(workflowSpec); + } + + return config; + } + + @SuppressWarnings({"unchecked"}) + private

ResolvedControllerConfiguration

controllerConfiguration( + Class> reconcilerClass, + io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration annotation) { + final var resourceClass = getResourceClassResolver().getPrimaryResourceClass(reconcilerClass); + + final var name = ReconcilerUtils.getNameFor(reconcilerClass); + final var generationAware = + valueOrDefaultFromAnnotation( + annotation, + io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration + ::generationAwareEventProcessing, + "generationAwareEventProcessing"); + final var associatedReconcilerClass = + ResolvedControllerConfiguration.getAssociatedReconcilerClassName(reconcilerClass); + + final var context = Utils.contextFor(name); + final Class retryClass = + valueOrDefaultFromAnnotation( + annotation, + io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration::retry, + "retry"); + final var retry = + Utils.instantiateAndConfigureIfNeeded( + retryClass, Retry.class, context, configuratorFor(Retry.class, reconcilerClass)); + + @SuppressWarnings("rawtypes") + final Class rateLimiterClass = + valueOrDefaultFromAnnotation( + annotation, + io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration::rateLimiter, + "rateLimiter"); + final var rateLimiter = + Utils.instantiateAndConfigureIfNeeded( + rateLimiterClass, + RateLimiter.class, + context, + configuratorFor(RateLimiter.class, reconcilerClass)); + + final var reconciliationInterval = + valueOrDefaultFromAnnotation( + annotation, + io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration + ::maxReconciliationInterval, + "maxReconciliationInterval"); + long interval = -1; + TimeUnit timeUnit = null; + if (reconciliationInterval != null && reconciliationInterval.interval() > 0) { + interval = reconciliationInterval.interval(); + timeUnit = reconciliationInterval.timeUnit(); + } + + var fieldManager = + valueOrDefaultFromAnnotation( + annotation, + io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration::fieldManager, + "fieldManager"); + final var dependentFieldManager = + fieldManager.equals(CONTROLLER_NAME_AS_FIELD_MANAGER) ? name : fieldManager; + + InformerConfiguration

informerConfig = + InformerConfiguration.builder(resourceClass) + .initFromAnnotation(annotation != null ? annotation.informer() : null, context) + .buildForController(); + + return new ResolvedControllerConfiguration

( + name, + generationAware, + associatedReconcilerClass, + retry, + rateLimiter, + ResolvedControllerConfiguration.getMaxReconciliationInterval(interval, timeUnit), + valueOrDefaultFromAnnotation( + annotation, + io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration::finalizerName, + "finalizerName"), + null, + dependentFieldManager, + this, + informerConfig); + } + + /** + * @deprecated This method was meant to allow subclasses to prevent automatic creation of the + * configuration when not found. This functionality is now removed, if you want to be able to + * prevent automated, on-demand creation of a reconciler's configuration, please use the + * {@link AbstractConfigurationService} implementation instead as base for your extension. + */ + @Deprecated(forRemoval = true) + protected boolean createIfNeeded() { + return true; + } + + @Override + public boolean checkCRDAndValidateLocalModel() { + return Utils.shouldCheckCRDAndValidateLocalModel(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Cloner.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Cloner.java new file mode 100644 index 0000000000..08cccab6f7 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Cloner.java @@ -0,0 +1,8 @@ +package io.javaoperatorsdk.operator.api.config; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public interface Cloner { + + R clone(R object); +} 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 new file mode 100644 index 0000000000..41134e64ac --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java @@ -0,0 +1,524 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig; +import io.javaoperatorsdk.operator.processing.dependent.workflow.ManagedWorkflowFactory; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; + +/** An interface from which to retrieve configuration information. */ +public interface ConfigurationService { + + Logger log = LoggerFactory.getLogger(ConfigurationService.class); + + int DEFAULT_MAX_CONCURRENT_REQUEST = 512; + + /** The default numbers of concurrent reconciliations */ + int DEFAULT_RECONCILIATION_THREADS_NUMBER = 50; + + /** The default number of threads used to process dependent workflows */ + int DEFAULT_WORKFLOW_EXECUTOR_THREAD_NUMBER = DEFAULT_RECONCILIATION_THREADS_NUMBER; + + /** + * Creates a new {@link ConfigurationService} instance used to configure an {@link + * io.javaoperatorsdk.operator.Operator} instance, starting from the specified base configuration + * and overriding specific aspects according to the provided {@link ConfigurationServiceOverrider} + * instance. + * + *

NOTE: This overriding mechanism should only be used before + * creating your Operator instance as the configuration service is set at creation time and cannot + * be subsequently changed. As a result, overriding values this way after the Operator has been + * configured will not take effect. + * + * @param baseConfiguration the {@link ConfigurationService} to start from + * @param overrider the {@link ConfigurationServiceOverrider} used to change the values provided + * by the base configuration + * @return a new {@link ConfigurationService} starting from the configuration provided as base but + * with overridden values. + */ + static ConfigurationService newOverriddenConfigurationService( + ConfigurationService baseConfiguration, Consumer overrider) { + if (overrider != null) { + final var toOverride = new ConfigurationServiceOverrider(baseConfiguration); + overrider.accept(toOverride); + return toOverride.build(); + } + return baseConfiguration; + } + + /** + * Creates a new {@link ConfigurationService} instance used to configure an {@link + * io.javaoperatorsdk.operator.Operator} instance, starting from the default configuration and + * overriding specific aspects according to the provided {@link ConfigurationServiceOverrider} + * instance. + * + *

NOTE: This overriding mechanism should only be used before + * creating your Operator instance as the configuration service is set at creation time and cannot + * be subsequently changed. As a result, overriding values this way after the Operator has been + * configured will not take effect. + * + * @param overrider the {@link ConfigurationServiceOverrider} used to change the values provided + * by the default configuration + * @return a new {@link ConfigurationService} overriding the default values with the ones provided + * by the specified {@link ConfigurationServiceOverrider} + * @since 4.4.0 + */ + static ConfigurationService newOverriddenConfigurationService( + Consumer overrider) { + return newOverriddenConfigurationService(new BaseConfigurationService(), overrider); + } + + /** + * Retrieves the configuration associated with the specified reconciler + * + * @param reconciler the reconciler we want the configuration of + * @param the {@code CustomResource} type associated with the specified reconciler + * @return the {@link ControllerConfiguration} associated with the specified reconciler or {@code + * null} if no configuration exists for the reconciler + */ + ControllerConfiguration getConfigurationFor(Reconciler reconciler); + + /** + * Used to clone custom resources. + * + *

NOTE: It is strongly suggested that implementors override this method since the + * default implementation creates a new {@link Cloner} instance each time this method is called. + * + * @return the configured {@link Cloner} + */ + default Cloner getResourceCloner() { + return new Cloner() { + @Override + public R clone(R object) { + return getKubernetesClient().getKubernetesSerialization().clone(object); + } + }; + } + + /** + * Provides the fully configured {@link KubernetesClient} to use for controllers to the target + * cluster. Note that this client only needs to be able to connect to the cluster, the SDK will + * take care of creating the required connections to watch the target resources (in particular, + * you do not need to worry about setting the namespace information in most cases). + * + *

Previous versions of this class provided direct access to the serialization mechanism (via + * {@link com.fasterxml.jackson.databind.ObjectMapper}) or the client's configuration. This was + * somewhat confusing, in particular with respect to changes made in the Fabric8 client + * serialization architecture made in 6.7. The proper way to configure these aspects is now to + * configure the Kubernetes client accordingly and the SDK will extract the information it needs + * from this instance. The recommended way to do so is to create your operator with {@link + * io.javaoperatorsdk.operator.Operator#Operator(Consumer)}, passing your custom instance with + * {@link ConfigurationServiceOverrider#withKubernetesClient(KubernetesClient)}. + * + *

NOTE: It is strongly suggested that implementors override this method since the + * default implementation creates a new {@link KubernetesClient} instance each time this method is + * called. + * + * @return the configured {@link KubernetesClient} + * @since 4.4.0 + */ + default KubernetesClient getKubernetesClient() { + return new KubernetesClientBuilder() + .withConfig( + new ConfigBuilder(Config.autoConfigure(null)) + .withMaxConcurrentRequests(DEFAULT_MAX_CONCURRENT_REQUEST) + .build()) + .withKubernetesSerialization(new KubernetesSerialization()) + .build(); + } + + /** + * Retrieves the set of the names of reconcilers for which a configuration exists + * + * @return the set of known reconciler names + */ + Set getKnownReconcilerNames(); + + /** + * Retrieves the {@link Version} information associated with this particular instance of the SDK + * + * @return the version information + */ + Version getVersion(); + + /** + * Whether the operator should query the CRD to make sure it's deployed and validate {@link + * CustomResource} implementations before attempting to register the associated reconcilers. + * + *

Note that this might require elevating the privileges associated with the operator to gain + * read access on the CRD resources. + * + * @return {@code true} if CRDs should be checked (default), {@code false} otherwise + */ + default boolean checkCRDAndValidateLocalModel() { + return false; + } + + /** + * The number of threads the operator can spin out to dispatch reconciliation requests to + * reconcilers with the default executors + * + * @return the number of concurrent reconciliation threads + */ + default int concurrentReconciliationThreads() { + return DEFAULT_RECONCILIATION_THREADS_NUMBER; + } + + /** + * Number of threads the operator can spin out to be used in the workflows with the default + * executor. + * + * @return the maximum number of concurrent workflow threads + */ + default int concurrentWorkflowExecutorThreads() { + return DEFAULT_WORKFLOW_EXECUTOR_THREAD_NUMBER; + } + + /** + * Override to provide a custom {@link Metrics} implementation + * + * @return the {@link Metrics} implementation + */ + default Metrics getMetrics() { + return Metrics.NOOP; + } + + /** + * Override to provide a custom {@link ExecutorService} implementation to change how threads + * handle concurrent reconciliations + * + * @return the {@link ExecutorService} implementation to use for concurrent reconciliation + * processing + */ + default ExecutorService getExecutorService() { + return Executors.newFixedThreadPool(concurrentReconciliationThreads()); + } + + /** + * Override to provide a custom {@link ExecutorService} implementation to change how dependent + * workflows are processed in parallel + * + * @return the {@link ExecutorService} implementation to use for dependent workflow processing + */ + default ExecutorService getWorkflowExecutorService() { + return Executors.newFixedThreadPool(concurrentWorkflowExecutorThreads()); + } + + /** + * Determines whether the associated Kubernetes client should be closed when the associated {@link + * io.javaoperatorsdk.operator.Operator} is stopped. + * + * @return {@code true} if the Kubernetes should be closed on stop, {@code false} otherwise + */ + default boolean closeClientOnStop() { + return true; + } + + /** + * Override to provide a custom {@link DependentResourceFactory} implementation to change how + * {@link io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource} are instantiated + * + * @return the custom {@link DependentResourceFactory} implementation + */ + @SuppressWarnings("rawtypes") + default DependentResourceFactory dependentResourceFactory() { + return DependentResourceFactory.DEFAULT; + } + + /** + * Retrieves the optional {@link LeaderElectionConfiguration} to specify how the associated {@link + * io.javaoperatorsdk.operator.Operator} handles leader election to ensure only one instance of + * the operator runs on the cluster at any given time + * + * @return the {@link LeaderElectionConfiguration} + */ + default Optional getLeaderElectionConfiguration() { + return Optional.empty(); + } + + /** + * if true, operator stops if there are some issues with informers {@link + * io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource} or {@link + * ControllerEventSource} on startup. Other event sources may also respect this flag. + * + *

if false, the startup will ignore recoverable errors, caused for example by RBAC issues, and + * will try to reconnect periodically in the background. + * + * @return actual value described above + */ + default boolean stopOnInformerErrorDuringStartup() { + return true; + } + + /** + * Timeout for cache sync. In other words source start timeout. Note that is + * "stopOnInformerErrorDuringStartup" is true the operator will stop on timeout. Default is 2 + * minutes. + * + * @return Duration of sync timeout + */ + default Duration cacheSyncTimeout() { + return Duration.ofMinutes(2); + } + + /** + * This is the timeout value that allows the reconciliation threads to gracefully shut down. If no + * value is set, the default is immediate shutdown. + * + * @return The duration of time to wait before terminating the reconciliation threads + * @since 5.0.0 + */ + default Duration reconciliationTerminationTimeout() { + return Duration.ZERO; + } + + /** + * Handler for an informer stop. Informer stops if there is a non-recoverable error. Like received + * a resource that cannot be deserialized. + * + * @return an optional InformerStopHandler + */ + default Optional getInformerStoppedHandler() { + return Optional.of( + (informer, ex) -> { + // hasSynced is checked to verify that informer already started. If not started, in case + // of a fatal error the operator will stop, no need for explicit exit. + if (ex != null && informer.hasSynced()) { + log.error("Fatal error in informer: {}. Stopping the operator", informer, ex); + System.exit(1); + } else { + log.debug( + "Informer stopped: {}. Has synced: {}, Error: {}. This can happen as a result of " + + "stopping the controller, or due to an error on startup." + + "See also stopOnInformerErrorDuringStartup configuration.", + informer, + informer.hasSynced(), + ex); + } + }); + } + + /** + * Override to provide a custom {@link ManagedWorkflowFactory} implementation to change how {@link + * io.javaoperatorsdk.operator.processing.dependent.workflow.ManagedWorkflow} are instantiated + * + * @return the custom {@link ManagedWorkflowFactory} implementation + */ + @SuppressWarnings("rawtypes") + default ManagedWorkflowFactory getWorkflowFactory() { + return ManagedWorkflowFactory.DEFAULT; + } + + /** + * Override to provide a custom {@link ExecutorServiceManager} implementation + * + * @return the custom {@link ExecutorServiceManager} implementation + */ + default ExecutorServiceManager getExecutorServiceManager() { + return new ExecutorServiceManager(this); + } + + /** + * Allows to revert to the 4.3 behavior when it comes to creating, updating and matching + * Kubernetes Dependent Resources when set to {@code false}. The default approach how these + * resources are created/updated and match was change to use Server-Side Apply + * (SSA) by default. + * + *

SSA based create/update can be still used with the legacy matching, just overriding the + * match method of Kubernetes Dependent Resource. + * + * @return {@code true} if SSA should be used for dependent resources, {@code false} otherwise + * @since 4.4.0 + */ + default boolean ssaBasedCreateUpdateMatchForDependentResources() { + return true; + } + + /** + * This is mostly useful as an integration point for downstream projects to be able to reuse the + * logic used to determine whether a given {@link KubernetesDependentResource} should use SSA or + * not. + * + * @param dependentResource the {@link KubernetesDependentResource} under consideration + * @param the dependent resource type + * @param

the primary resource type + * @return {@code true} if SSA should be used for + * @since 4.9.4 + */ + default boolean shouldUseSSA( + KubernetesDependentResource dependentResource) { + return shouldUseSSA( + dependentResource.getClass(), + dependentResource.resourceType(), + dependentResource.configuration().orElse(null)); + } + + /** + * This is mostly useful as an integration point for downstream projects to be able to reuse the + * logic used to determine whether a given {@link KubernetesDependentResource} type should use SSA + * or not. + * + * @param dependentResourceType the {@link KubernetesDependentResource} type under consideration + * @param resourceType the resource type associated with the considered dependent resource type + * @return {@code true} if SSA should be used for specified dependent resource type, {@code false} + * otherwise + * @since 4.9.5 + */ + @SuppressWarnings("rawtypes") + default boolean shouldUseSSA( + Class dependentResourceType, + Class resourceType, + KubernetesDependentResourceConfig config) { + Boolean useSSAConfig = + Optional.ofNullable(config).map(KubernetesDependentResourceConfig::useSSA).orElse(null); + // don't use SSA for certain resources by default, only if explicitly overridden + if (useSSAConfig == null) { + if (defaultNonSSAResources().contains(resourceType)) { + return false; + } else { + return ssaBasedCreateUpdateMatchForDependentResources(); + } + } else { + return useSSAConfig; + } + } + + /** + * Returns the set of default resources for which Server-Side Apply (SSA) will not be used, even + * if it is the default behavior for dependent resources as specified by {@link + * #ssaBasedCreateUpdateMatchForDependentResources()}. The exception to this is in the case where + * the use of SSA is explicitly enabled on the dependent resource directly using {@link + * KubernetesDependent#useSSA()}. + * + *

By default, SSA is disabled for {@link ConfigMap} and {@link Secret} resources. + * + * @return The set of resource types for which SSA will not be used + */ + default Set> defaultNonSSAResources() { + return Set.of(ConfigMap.class, Secret.class); + } + + /** + * @deprecated Use {@link #defaultNonSSAResources()} instead + */ + @Deprecated(forRemoval = true) + 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 + * adding finalizers, patching resources and status. + * + * @return {@code true} if Server-Side Apply (SSA) should be used when patching the primary + * resources, {@code false} otherwise + * @see ConfigurationServiceOverrider#withUseSSAToPatchPrimaryResource(boolean) + * @since 5.0.0 + */ + default boolean useSSAToPatchPrimaryResource() { + return true; + } + + /** + * Determines whether resources retrieved from caches such as via calls to {@link + * Context#getSecondaryResource(Class)} should be defensively cloned first. + * + *

Defensive cloning to prevent problematic cache modifications (modifying the resource would + * otherwise modify the stored copy in the cache) was transparently done in previous JOSDK + * versions. This might have performance consequences and, with the more prevalent use of + * Server-Side Apply, where you should create a new copy of your resource with only modified + * fields, such modifications of these resources are less likely to occur. + * + * @return {@code true} if resources should be defensively cloned before returning them from + * caches, {@code false} otherwise + * @since 5.0.0 + */ + default boolean cloneSecondaryResourcesWhenGettingFromCache() { + return false; + } +} 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 new file mode 100644 index 0000000000..be86cbe312 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java @@ -0,0 +1,355 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory; + +@SuppressWarnings({"unused", "UnusedReturnValue"}) +public class ConfigurationServiceOverrider { + + private static final Logger log = LoggerFactory.getLogger(ConfigurationServiceOverrider.class); + private final ConfigurationService original; + private Metrics metrics; + private Boolean checkCR; + private Integer concurrentReconciliationThreads; + private Integer concurrentWorkflowExecutorThreads; + private Cloner cloner; + private Boolean closeClientOnStop; + private KubernetesClient client; + private ExecutorService executorService; + private ExecutorService workflowExecutorService; + private LeaderElectionConfiguration leaderElectionConfiguration; + private InformerStoppedHandler informerStoppedHandler; + private Boolean stopOnInformerErrorDuringStartup; + private Duration cacheSyncTimeout; + 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; + + ConfigurationServiceOverrider(ConfigurationService original) { + this.original = original; + } + + public ConfigurationServiceOverrider checkingCRDAndValidateLocalModel(boolean check) { + this.checkCR = check; + return this; + } + + public ConfigurationServiceOverrider withConcurrentReconciliationThreads(int threadNumber) { + this.concurrentReconciliationThreads = threadNumber; + return this; + } + + public ConfigurationServiceOverrider withConcurrentWorkflowExecutorThreads(int threadNumber) { + this.concurrentWorkflowExecutorThreads = threadNumber; + return this; + } + + @SuppressWarnings("rawtypes") + public ConfigurationServiceOverrider withDependentResourceFactory( + DependentResourceFactory dependentResourceFactory) { + this.dependentResourceFactory = dependentResourceFactory; + return this; + } + + public ConfigurationServiceOverrider withResourceCloner(Cloner cloner) { + this.cloner = cloner; + return this; + } + + public ConfigurationServiceOverrider withMetrics(Metrics metrics) { + this.metrics = metrics; + return this; + } + + public ConfigurationServiceOverrider withCloseClientOnStop(boolean close) { + this.closeClientOnStop = close; + return this; + } + + public ConfigurationServiceOverrider withExecutorService(ExecutorService executorService) { + this.executorService = executorService; + return this; + } + + public ConfigurationServiceOverrider withWorkflowExecutorService( + ExecutorService workflowExecutorService) { + this.workflowExecutorService = workflowExecutorService; + return this; + } + + /** + * Replaces the default {@link KubernetesClient} instance by the specified one. This is the + * preferred mechanism to configure which client will be used to access the cluster. + * + *

Note that when {@link Operator#stop()} is called, by default the client is closed even if + * explicitly provided with this method. Use {@link #withCloseClientOnStop(boolean)} to change + * this behavior. + * + * @param client the fully configured client to use for cluster access + * @return this {@link ConfigurationServiceOverrider} for chained customization + */ + public ConfigurationServiceOverrider withKubernetesClient(KubernetesClient client) { + this.client = client; + return this; + } + + public ConfigurationServiceOverrider withLeaderElectionConfiguration( + LeaderElectionConfiguration leaderElectionConfiguration) { + this.leaderElectionConfiguration = leaderElectionConfiguration; + return this; + } + + public ConfigurationServiceOverrider withInformerStoppedHandler(InformerStoppedHandler handler) { + this.informerStoppedHandler = handler; + return this; + } + + public ConfigurationServiceOverrider withStopOnInformerErrorDuringStartup( + boolean stopOnInformerErrorDuringStartup) { + this.stopOnInformerErrorDuringStartup = stopOnInformerErrorDuringStartup; + return this; + } + + public ConfigurationServiceOverrider withCacheSyncTimeout(Duration cacheSyncTimeout) { + this.cacheSyncTimeout = cacheSyncTimeout; + return this; + } + + public ConfigurationServiceOverrider withReconciliationTerminationTimeout( + Duration reconciliationTerminationTimeout) { + this.reconciliationTerminationTimeout = reconciliationTerminationTimeout; + return this; + } + + public ConfigurationServiceOverrider withSSABasedCreateUpdateMatchForDependentResources( + boolean value) { + this.ssaBasedCreateUpdateMatchForDependentResources = value; + return this; + } + + public ConfigurationServiceOverrider withDefaultNonSSAResource( + Set> defaultNonSSAResource) { + this.defaultNonSSAResource = defaultNonSSAResource; + 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; + } + + public ConfigurationServiceOverrider withCloneSecondaryResourcesWhenGettingFromCache( + boolean value) { + this.cloneSecondaryResourcesWhenGettingFromCache = value; + return this; + } + + public ConfigurationServiceOverrider withPreviousAnnotationForDependentResourcesBlocklist( + Set> blocklist) { + this.previousAnnotationUsageBlocklist = blocklist; + return this; + } + + public ConfigurationService build() { + return new BaseConfigurationService(original.getVersion(), cloner, client) { + @Override + public Set getKnownReconcilerNames() { + return original.getKnownReconcilerNames(); + } + + private T overriddenValueOrDefault( + T value, Function defaultValue) { + return value != null ? value : defaultValue.apply(original); + } + + @Override + public boolean checkCRDAndValidateLocalModel() { + return overriddenValueOrDefault( + checkCR, ConfigurationService::checkCRDAndValidateLocalModel); + } + + @SuppressWarnings("rawtypes") + @Override + public DependentResourceFactory dependentResourceFactory() { + return overriddenValueOrDefault( + dependentResourceFactory, ConfigurationService::dependentResourceFactory); + } + + @Override + public int concurrentReconciliationThreads() { + return Utils.ensureValid( + overriddenValueOrDefault( + concurrentReconciliationThreads, + ConfigurationService::concurrentReconciliationThreads), + "maximum reconciliation threads", + 1, + original.concurrentReconciliationThreads()); + } + + @Override + public int concurrentWorkflowExecutorThreads() { + return Utils.ensureValid( + overriddenValueOrDefault( + concurrentWorkflowExecutorThreads, + ConfigurationService::concurrentWorkflowExecutorThreads), + "maximum workflow execution threads", + 1, + original.concurrentWorkflowExecutorThreads()); + } + + @Override + public Metrics getMetrics() { + return overriddenValueOrDefault(metrics, ConfigurationService::getMetrics); + } + + @Override + public boolean closeClientOnStop() { + return overriddenValueOrDefault(closeClientOnStop, ConfigurationService::closeClientOnStop); + } + + @Override + public ExecutorService getExecutorService() { + if (executorService != null) { + return executorService; + } else { + return super.getExecutorService(); + } + } + + @Override + public ExecutorService getWorkflowExecutorService() { + if (workflowExecutorService != null) { + return workflowExecutorService; + } else { + return super.getWorkflowExecutorService(); + } + } + + @Override + public Optional getLeaderElectionConfiguration() { + return leaderElectionConfiguration != null + ? Optional.of(leaderElectionConfiguration) + : original.getLeaderElectionConfiguration(); + } + + @Override + public Optional getInformerStoppedHandler() { + return informerStoppedHandler != null + ? Optional.of(informerStoppedHandler) + : original.getInformerStoppedHandler(); + } + + @Override + public boolean stopOnInformerErrorDuringStartup() { + return overriddenValueOrDefault( + stopOnInformerErrorDuringStartup, + ConfigurationService::stopOnInformerErrorDuringStartup); + } + + @Override + public Duration cacheSyncTimeout() { + return overriddenValueOrDefault(cacheSyncTimeout, ConfigurationService::cacheSyncTimeout); + } + + @Override + public Duration reconciliationTerminationTimeout() { + return overriddenValueOrDefault( + reconciliationTerminationTimeout, + ConfigurationService::reconciliationTerminationTimeout); + } + + @Override + public boolean ssaBasedCreateUpdateMatchForDependentResources() { + return overriddenValueOrDefault( + ssaBasedCreateUpdateMatchForDependentResources, + ConfigurationService::ssaBasedCreateUpdateMatchForDependentResources); + } + + @Override + public Set> defaultNonSSAResources() { + return overriddenValueOrDefault( + 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( + useSSAToPatchPrimaryResource, ConfigurationService::useSSAToPatchPrimaryResource); + } + + @Override + public boolean cloneSecondaryResourcesWhenGettingFromCache() { + return overriddenValueOrDefault( + 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 new file mode 100644 index 0000000000..2c18fa55d3 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java @@ -0,0 +1,95 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.time.Duration; +import java.util.Optional; +import java.util.Set; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec; +import io.javaoperatorsdk.operator.api.reconciler.MaxReconciliationInterval; +import io.javaoperatorsdk.operator.processing.event.rate.LinearRateLimiter; +import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; +import io.javaoperatorsdk.operator.processing.retry.Retry; + +public interface ControllerConfiguration

extends Informable

{ + + @SuppressWarnings("rawtypes") + RateLimiter DEFAULT_RATE_LIMITER = LinearRateLimiter.deactivatedRateLimiter(); + + /** Will use the controller name as fieldManager if set. */ + String CONTROLLER_NAME_AS_FIELD_MANAGER = "use_controller_name"; + + default String getName() { + return ensureValidName(null, getAssociatedReconcilerClassName()); + } + + default String getFinalizerName() { + return ReconcilerUtils.getDefaultFinalizerName(getResourceClass()); + } + + static String ensureValidName(String name, String reconcilerClassName) { + return name != null ? name : ReconcilerUtils.getDefaultReconcilerName(reconcilerClassName); + } + + static String ensureValidFinalizerName(String finalizer, String resourceTypeName) { + if (finalizer != null && !finalizer.isBlank()) { + if (ReconcilerUtils.isFinalizerValid(finalizer)) { + return finalizer; + } else { + throw new IllegalArgumentException( + finalizer + + " is not a valid finalizer. See" + + " https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#finalizers" + + " for details"); + } + } else { + return ReconcilerUtils.getDefaultFinalizerName(resourceTypeName); + } + } + + default boolean isGenerationAware() { + return true; + } + + String getAssociatedReconcilerClassName(); + + default Retry getRetry() { + return GenericRetry.DEFAULT; + } + + @SuppressWarnings("rawtypes") + default RateLimiter getRateLimiter() { + return DEFAULT_RATE_LIMITER; + } + + default Optional getWorkflowSpec() { + return Optional.empty(); + } + + default Optional maxReconciliationInterval() { + return Optional.of(Duration.ofHours(MaxReconciliationInterval.DEFAULT_INTERVAL)); + } + + ConfigurationService getConfigurationService(); + + @SuppressWarnings("unused") + default Set getEffectiveNamespaces() { + return getInformerConfig().getEffectiveNamespaces(this); + } + + /** + * Retrieves the name used to assign as field manager for Server-Side Apply + * (SSA) operations. If unset, the sanitized controller name will be used. + * + * @return the name used as field manager for SSA operations + */ + default String fieldManager() { + return getName(); + } + + C getConfigurationFor(DependentResourceSpec spec); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java new file mode 100644 index 0000000000..d2e37a397d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java @@ -0,0 +1,208 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.time.Duration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.informers.cache.ItemStore; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter; +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.processing.retry.Retry; + +@SuppressWarnings({"rawtypes", "unused", "UnusedReturnValue"}) +public class ControllerConfigurationOverrider { + + private final ControllerConfiguration original; + private String name; + private String finalizer; + private boolean generationAware; + private Retry retry; + private RateLimiter rateLimiter; + private String fieldManager; + private Duration reconciliationMaxInterval; + private Map configurations; + private final InformerConfiguration.Builder config; + + private ControllerConfigurationOverrider(ControllerConfiguration original) { + this.finalizer = original.getFinalizerName(); + this.generationAware = original.isGenerationAware(); + final var informerConfig = original.getInformerConfig(); + this.config = InformerConfiguration.builder(informerConfig); + this.retry = original.getRetry(); + this.reconciliationMaxInterval = original.maxReconciliationInterval().orElse(null); + this.original = original; + this.rateLimiter = original.getRateLimiter(); + this.name = original.getName(); + this.fieldManager = original.fieldManager(); + } + + public ControllerConfigurationOverrider withFinalizer(String finalizer) { + this.finalizer = finalizer; + return this; + } + + public ControllerConfigurationOverrider withGenerationAware(boolean generationAware) { + this.generationAware = generationAware; + return this; + } + + public ControllerConfigurationOverrider watchingOnlyCurrentNamespace() { + config.withWatchCurrentNamespace(); + return this; + } + + public ControllerConfigurationOverrider addingNamespaces(String... namespaces) { + if (namespaces != null && namespaces.length > 0) { + final var current = config.namespaces(); + final var aggregated = new HashSet(current.size() + namespaces.length); + aggregated.addAll(current); + aggregated.addAll(Set.of(namespaces)); + config.withNamespaces(aggregated); + } + return this; + } + + public ControllerConfigurationOverrider removingNamespaces(String... namespaces) { + if (namespaces != null && namespaces.length > 0) { + final var current = new HashSet<>(config.namespaces()); + List.of(namespaces).forEach(current::remove); + if (current.isEmpty()) { + return watchingAllNamespaces(); + } else { + config.withNamespaces(current); + } + } + return this; + } + + public ControllerConfigurationOverrider settingNamespaces(Set newNamespaces) { + config.withNamespaces(newNamespaces); + return this; + } + + public ControllerConfigurationOverrider settingNamespaces(String... newNamespaces) { + return settingNamespaces(Set.of(newNamespaces)); + } + + public ControllerConfigurationOverrider settingNamespace(String namespace) { + config.withNamespaces(Set.of(namespace)); + return this; + } + + public ControllerConfigurationOverrider watchingAllNamespaces() { + config.withWatchAllNamespaces(); + return this; + } + + public ControllerConfigurationOverrider withRetry(Retry retry) { + this.retry = retry; + return this; + } + + public ControllerConfigurationOverrider withRateLimiter(RateLimiter rateLimiter) { + this.rateLimiter = rateLimiter; + return this; + } + + public ControllerConfigurationOverrider withLabelSelector(String labelSelector) { + config.withLabelSelector(labelSelector); + return this; + } + + public ControllerConfigurationOverrider withReconciliationMaxInterval( + Duration reconciliationMaxInterval) { + this.reconciliationMaxInterval = reconciliationMaxInterval; + return this; + } + + public ControllerConfigurationOverrider withOnAddFilter(OnAddFilter onAddFilter) { + config.withOnAddFilter(onAddFilter); + return this; + } + + public ControllerConfigurationOverrider withOnUpdateFilter(OnUpdateFilter onUpdateFilter) { + config.withOnUpdateFilter(onUpdateFilter); + return this; + } + + public ControllerConfigurationOverrider withGenericFilter(GenericFilter genericFilter) { + config.withGenericFilter(genericFilter); + return this; + } + + public ControllerConfigurationOverrider withItemStore(ItemStore itemStore) { + config.withItemStore(itemStore); + return this; + } + + public ControllerConfigurationOverrider withName(String name) { + this.name = name; + config.withName(name); + return this; + } + + public ControllerConfigurationOverrider withFieldManager(String dependentFieldManager) { + this.fieldManager = dependentFieldManager; + return this; + } + + /** + * Sets a max page size limit when starting the informer. This will result in pagination while + * populating the cache. This means that longer lists will take multiple requests to fetch. See + * {@link io.fabric8.kubernetes.client.dsl.Informable#withLimit(Long)} for more details. + * + * @param informerListLimit null (the default) results in no pagination + */ + public ControllerConfigurationOverrider withInformerListLimit(Long informerListLimit) { + config.withInformerListLimit(informerListLimit); + return this; + } + + public ControllerConfigurationOverrider replacingNamedDependentResourceConfig( + String name, Object dependentResourceConfig) { + + final var specs = original.getWorkflowSpec().orElseThrow().getDependentResourceSpecs(); + final var spec = + specs.stream() + .filter(drs -> drs.getName().equals(name)) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException("Cannot find a DependentResource named: " + name)); + + if (configurations == null) { + configurations = new HashMap<>(specs.size()); + } + configurations.put(spec, dependentResourceConfig); + return this; + } + + public ControllerConfiguration build() { + return new ResolvedControllerConfiguration<>( + name, + generationAware, + original.getAssociatedReconcilerClassName(), + retry, + rateLimiter, + reconciliationMaxInterval, + finalizer, + configurations, + fieldManager, + original.getConfigurationService(), + config.buildForController(), + original.getWorkflowSpec().orElse(null)); + } + + public static ControllerConfigurationOverrider override( + ControllerConfiguration original) { + return new ControllerConfigurationOverrider<>(original); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultResourceClassResolver.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultResourceClassResolver.java new file mode 100644 index 0000000000..cf44b9890e --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultResourceClassResolver.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.api.config; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; + +public class DefaultResourceClassResolver implements ResourceClassResolver { + + @SuppressWarnings("unchecked") + @Override + public Class getPrimaryResourceClass( + Class> reconcilerClass) { + return (Class) + Utils.getFirstTypeArgumentFromSuperClassOrInterface(reconcilerClass, Reconciler.class); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManager.java new file mode 100644 index 0000000000..3cbf68d8fe --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ExecutorServiceManager.java @@ -0,0 +1,242 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.OperatorException; + +public class ExecutorServiceManager { + + private static final Logger log = LoggerFactory.getLogger(ExecutorServiceManager.class); + private ExecutorService executor; + private ExecutorService workflowExecutor; + private ExecutorService cachingExecutorService; + private boolean started; + private ConfigurationService configurationService; + + ExecutorServiceManager(ConfigurationService configurationService) { + start(configurationService); + } + + /** + * Uses cachingExecutorService from this manager. Use this only for tasks, that don't have dynamic + * nature, in sense that won't grow with the number of inputs (thus kubernetes resources) + * + * @param stream of elements + * @param task to call on stream elements + * @param threadNamer for naming thread + * @param type + */ + public void boundedExecuteAndWaitForAllToComplete( + Stream stream, Function task, Function threadNamer) { + executeAndWaitForAllToComplete(stream, task, threadNamer, cachingExecutorService()); + } + + public static void executeAndWaitForAllToComplete( + Stream stream, + Function task, + Function threadNamer, + ExecutorService executorService) { + final var instrumented = new InstrumentedExecutorService(executorService); + try { + instrumented + .invokeAll( + stream + .map( + item -> + (Callable) + () -> { + // change thread name for easier debugging + final var thread = Thread.currentThread(); + final var name = thread.getName(); + thread.setName(threadNamer.apply(item)); + try { + task.apply(item); + return null; + } finally { + // restore original name + thread.setName(name); + } + }) + .collect(Collectors.toList())) + .forEach( + f -> { + try { + // to find out any exceptions + f.get(); + } catch (ExecutionException e) { + throw new OperatorException(e); + } catch (InterruptedException e) { + log.warn("Interrupted.", e); + Thread.currentThread().interrupt(); + } + }); + } catch (InterruptedException e) { + log.warn("Interrupted.", e); + Thread.currentThread().interrupt(); + } + } + + public ExecutorService reconcileExecutorService() { + return executor; + } + + public ExecutorService workflowExecutorService() { + lazyInitWorkflowExecutorService(); + return workflowExecutor; + } + + private synchronized void lazyInitWorkflowExecutorService() { + if (workflowExecutor == null) { + workflowExecutor = + new InstrumentedExecutorService(configurationService.getWorkflowExecutorService()); + } + } + + public ExecutorService cachingExecutorService() { + return cachingExecutorService; + } + + public void start(ConfigurationService configurationService) { + if (!started) { + this.configurationService = configurationService; // used to lazy init workflow executor + this.cachingExecutorService = Executors.newCachedThreadPool(); + this.executor = new InstrumentedExecutorService(configurationService.getExecutorService()); + started = true; + } + } + + public void stop(Duration gracefulShutdownTimeout) { + try { + log.debug("Closing executor"); + var parallelExec = Executors.newFixedThreadPool(3); + parallelExec.invokeAll( + List.of( + shutdown(executor, gracefulShutdownTimeout), + shutdown(workflowExecutor, gracefulShutdownTimeout), + shutdown(cachingExecutorService, gracefulShutdownTimeout))); + workflowExecutor = null; + parallelExec.shutdownNow(); + started = false; + } catch (InterruptedException e) { + log.debug("Exception closing executor: {}", e.getLocalizedMessage()); + Thread.currentThread().interrupt(); + } + } + + private static Callable shutdown( + ExecutorService executorService, Duration gracefulShutdownTimeout) { + return () -> { + // workflow executor can be null + if (executorService == null) { + return null; + } + executorService.shutdown(); + if (!executorService.awaitTermination( + gracefulShutdownTimeout.toMillis(), TimeUnit.MILLISECONDS)) { + executorService.shutdownNow(); // if we timed out, waiting, cancel everything + } + return null; + }; + } + + private static class InstrumentedExecutorService implements ExecutorService { + private final boolean debug; + private final ExecutorService executor; + + private InstrumentedExecutorService(ExecutorService executor) { + if (executor == null) { + throw new NullPointerException(); + } + this.executor = executor; + debug = Utils.debugThreadPool(); + } + + @Override + public void shutdown() { + if (debug) { + Thread.dumpStack(); + } + executor.shutdown(); + } + + @Override + public List shutdownNow() { + return executor.shutdownNow(); + } + + @Override + public boolean isShutdown() { + return executor.isShutdown(); + } + + @Override + public boolean isTerminated() { + return executor.isTerminated(); + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException { + return executor.awaitTermination(timeout, unit); + } + + @Override + public Future submit(Callable task) { + return executor.submit(task); + } + + @Override + public Future submit(Runnable task, T result) { + return executor.submit(task, result); + } + + @Override + public Future submit(Runnable task) { + return executor.submit(task); + } + + @Override + public List> invokeAll(Collection> tasks) + throws InterruptedException { + return executor.invokeAll(tasks); + } + + @Override + public List> invokeAll( + Collection> tasks, long timeout, TimeUnit unit) + throws InterruptedException { + return executor.invokeAll(tasks, timeout, unit); + } + + @Override + public T invokeAny(Collection> tasks) + throws InterruptedException, ExecutionException { + return executor.invokeAny(tasks); + } + + @Override + public T invokeAny(Collection> tasks, long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + return executor.invokeAny(tasks, timeout, unit); + } + + @Override + public void execute(Runnable command) { + executor.execute(command); + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Informable.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Informable.java new file mode 100644 index 0000000000..39272b2083 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Informable.java @@ -0,0 +1,17 @@ +package io.javaoperatorsdk.operator.api.config; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; + +public interface Informable { + + default String getResourceTypeName() { + return getInformerConfig().getResourceTypeName(); + } + + InformerConfiguration getInformerConfig(); + + default Class getResourceClass() { + return getInformerConfig().getResourceClass(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/InformerStoppedHandler.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/InformerStoppedHandler.java new file mode 100644 index 0000000000..d204c86664 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/InformerStoppedHandler.java @@ -0,0 +1,9 @@ +package io.javaoperatorsdk.operator.api.config; + +import io.fabric8.kubernetes.client.informers.SharedIndexInformer; + +public interface InformerStoppedHandler { + + @SuppressWarnings("rawtypes") + void onStop(SharedIndexInformer informer, Throwable ex); +} 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 new file mode 100644 index 0000000000..49efa10b8d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfiguration.java @@ -0,0 +1,120 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.time.Duration; +import java.util.Optional; + +import io.fabric8.kubernetes.client.extended.leaderelection.LeaderCallbacks; + +public class LeaderElectionConfiguration { + + public static final Duration LEASE_DURATION_DEFAULT_VALUE = Duration.ofSeconds(15); + public static final Duration RENEW_DEADLINE_DEFAULT_VALUE = Duration.ofSeconds(10); + public static final Duration RETRY_PERIOD_DEFAULT_VALUE = Duration.ofSeconds(2); + + private final String leaseName; + private final String leaseNamespace; + private final String identity; + + private final Duration leaseDuration; + private final Duration renewDeadline; + private final Duration retryPeriod; + + private final LeaderCallbacks leaderCallbacks; + private final boolean exitOnStopLeading; + + public LeaderElectionConfiguration(String leaseName, String leaseNamespace, String identity) { + this( + leaseName, + leaseNamespace, + LEASE_DURATION_DEFAULT_VALUE, + RENEW_DEADLINE_DEFAULT_VALUE, + RETRY_PERIOD_DEFAULT_VALUE, + identity, + null, + 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); + } + + public LeaderElectionConfiguration(String leaseName) { + this( + leaseName, + null, + LEASE_DURATION_DEFAULT_VALUE, + RENEW_DEADLINE_DEFAULT_VALUE, + RETRY_PERIOD_DEFAULT_VALUE, + null, + null, + true); + } + + public LeaderElectionConfiguration( + String leaseName, + String leaseNamespace, + Duration leaseDuration, + Duration renewDeadline, + Duration retryPeriod) { + this(leaseName, leaseNamespace, leaseDuration, renewDeadline, retryPeriod, null, null, true); + } + + public LeaderElectionConfiguration( + String leaseName, + String leaseNamespace, + Duration leaseDuration, + Duration renewDeadline, + Duration retryPeriod, + String identity, + LeaderCallbacks leaderCallbacks, + boolean exitOnStopLeading) { + this.leaseName = leaseName; + this.leaseNamespace = leaseNamespace; + this.leaseDuration = leaseDuration; + this.renewDeadline = renewDeadline; + this.retryPeriod = retryPeriod; + this.identity = identity; + this.leaderCallbacks = leaderCallbacks; + this.exitOnStopLeading = exitOnStopLeading; + } + + public Optional getLeaseNamespace() { + return Optional.ofNullable(leaseNamespace); + } + + public String getLeaseName() { + return leaseName; + } + + public Duration getLeaseDuration() { + return leaseDuration; + } + + public Duration getRenewDeadline() { + return renewDeadline; + } + + public Duration getRetryPeriod() { + return retryPeriod; + } + + public Optional getIdentity() { + return Optional.ofNullable(identity); + } + + public Optional getLeaderCallbacks() { + return Optional.ofNullable(leaderCallbacks); + } + + public boolean isExitOnStopLeading() { + return exitOnStopLeading; + } +} 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 new file mode 100644 index 0000000000..c4d4fc6190 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/LeaderElectionConfigurationBuilder.java @@ -0,0 +1,75 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.time.Duration; + +import io.fabric8.kubernetes.client.extended.leaderelection.LeaderCallbacks; + +import static io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration.*; + +@SuppressWarnings("unused") +public final class LeaderElectionConfigurationBuilder { + + private final String leaseName; + private String leaseNamespace; + private String identity; + private Duration leaseDuration = LEASE_DURATION_DEFAULT_VALUE; + 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; + } + + public static LeaderElectionConfigurationBuilder aLeaderElectionConfiguration(String leaseName) { + return new LeaderElectionConfigurationBuilder(leaseName); + } + + public LeaderElectionConfigurationBuilder withLeaseNamespace(String leaseNamespace) { + this.leaseNamespace = leaseNamespace; + return this; + } + + public LeaderElectionConfigurationBuilder withIdentity(String identity) { + this.identity = identity; + return this; + } + + public LeaderElectionConfigurationBuilder withLeaseDuration(Duration leaseDuration) { + this.leaseDuration = leaseDuration; + return this; + } + + public LeaderElectionConfigurationBuilder withRenewDeadline(Duration renewDeadline) { + this.renewDeadline = renewDeadline; + return this; + } + + public LeaderElectionConfigurationBuilder withRetryPeriod(Duration retryPeriod) { + this.retryPeriod = retryPeriod; + return this; + } + + public LeaderElectionConfigurationBuilder withLeaderCallbacks(LeaderCallbacks leaderCallbacks) { + this.leaderCallbacks = leaderCallbacks; + return this; + } + + public LeaderElectionConfigurationBuilder withExitOnStopLeading(boolean exitOnStopLeading) { + this.exitOnStopLeading = exitOnStopLeading; + return this; + } + + public LeaderElectionConfiguration build() { + return new LeaderElectionConfiguration( + leaseName, + leaseNamespace, + leaseDuration, + renewDeadline, + retryPeriod, + identity, + leaderCallbacks, + exitOnStopLeading); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/NamespaceChangeable.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/NamespaceChangeable.java new file mode 100644 index 0000000000..6e6d53f49f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/NamespaceChangeable.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.util.Set; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_NAMESPACES_SET; + +public interface NamespaceChangeable { + + /** + * If the controller and possibly registered {@link + * io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource} watches a set + * of namespaces this set can be adjusted dynamically, this when the operator is running. + * + * @param namespaces target namespaces to watch + */ + void changeNamespaces(Set namespaces); + + @SuppressWarnings("unused") + default void changeNamespaces(String... namespaces) { + changeNamespaces(namespaces != null ? Set.of(namespaces) : DEFAULT_NAMESPACES_SET); + } + + default boolean allowsNamespaceChanges() { + return true; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResolvedControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResolvedControllerConfiguration.java new file mode 100644 index 0000000000..3c26659ed2 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResolvedControllerConfiguration.java @@ -0,0 +1,210 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter; +import io.javaoperatorsdk.operator.processing.retry.Retry; + +@SuppressWarnings("rawtypes") +public class ResolvedControllerConfiguration

+ implements io.javaoperatorsdk.operator.api.config.ControllerConfiguration

{ + + private final InformerConfiguration

informerConfig; + private final String name; + private final boolean generationAware; + private final String associatedReconcilerClassName; + private final Retry retry; + private final RateLimiter rateLimiter; + private final Duration maxReconciliationInterval; + private final String finalizer; + private final Map configurations; + private final ConfigurationService configurationService; + private final String fieldManager; + private WorkflowSpec workflowSpec; + + public ResolvedControllerConfiguration(ControllerConfiguration

other) { + this( + other.getName(), + other.isGenerationAware(), + other.getAssociatedReconcilerClassName(), + other.getRetry(), + other.getRateLimiter(), + other.maxReconciliationInterval().orElse(null), + other.getFinalizerName(), + Collections.emptyMap(), + other.fieldManager(), + other.getConfigurationService(), + other.getInformerConfig(), + other.getWorkflowSpec().orElse(null)); + } + + public ResolvedControllerConfiguration( + String name, + boolean generationAware, + String associatedReconcilerClassName, + Retry retry, + RateLimiter rateLimiter, + Duration maxReconciliationInterval, + String finalizer, + Map configurations, + String fieldManager, + ConfigurationService configurationService, + InformerConfiguration

informerConfig, + WorkflowSpec workflowSpec) { + this( + name, + generationAware, + associatedReconcilerClassName, + retry, + rateLimiter, + maxReconciliationInterval, + finalizer, + configurations, + fieldManager, + configurationService, + informerConfig); + setWorkflowSpec(workflowSpec); + } + + protected ResolvedControllerConfiguration( + String name, + boolean generationAware, + String associatedReconcilerClassName, + Retry retry, + RateLimiter rateLimiter, + Duration maxReconciliationInterval, + String finalizer, + Map configurations, + String fieldManager, + ConfigurationService configurationService, + InformerConfiguration

informerConfig) { + this.informerConfig = informerConfig; + this.configurationService = configurationService; + this.name = ControllerConfiguration.ensureValidName(name, associatedReconcilerClassName); + this.generationAware = generationAware; + this.associatedReconcilerClassName = associatedReconcilerClassName; + this.retry = ensureRetry(retry); + this.rateLimiter = ensureRateLimiter(rateLimiter); + this.maxReconciliationInterval = maxReconciliationInterval; + this.configurations = configurations != null ? configurations : Collections.emptyMap(); + this.finalizer = + ControllerConfiguration.ensureValidFinalizerName(finalizer, getResourceTypeName()); + this.fieldManager = fieldManager; + } + + protected ResolvedControllerConfiguration( + Class

resourceClass, + String name, + Class reconcilerClas, + ConfigurationService configurationService) { + this( + name, + false, + getAssociatedReconcilerClassName(reconcilerClas), + null, + null, + null, + null, + null, + null, + configurationService, + InformerConfiguration.builder(resourceClass).buildForController()); + } + + @Override + public InformerConfiguration

getInformerConfig() { + return informerConfig; + } + + public static Duration getMaxReconciliationInterval(long interval, TimeUnit timeUnit) { + return interval > 0 ? Duration.of(interval, timeUnit.toChronoUnit()) : null; + } + + public static String getAssociatedReconcilerClassName( + Class reconcilerClass) { + return reconcilerClass.getCanonicalName(); + } + + protected Retry ensureRetry(Retry given) { + return given == null ? ControllerConfiguration.super.getRetry() : given; + } + + protected RateLimiter ensureRateLimiter(RateLimiter given) { + return given == null ? ControllerConfiguration.super.getRateLimiter() : given; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getFinalizerName() { + return finalizer; + } + + @Override + public boolean isGenerationAware() { + return generationAware; + } + + @Override + public String getAssociatedReconcilerClassName() { + return associatedReconcilerClassName; + } + + @Override + public Retry getRetry() { + return retry; + } + + @Override + public RateLimiter getRateLimiter() { + return rateLimiter; + } + + @Override + public Optional getWorkflowSpec() { + return Optional.ofNullable(workflowSpec); + } + + public void setWorkflowSpec(WorkflowSpec workflowSpec) { + this.workflowSpec = workflowSpec; + } + + @Override + public Optional maxReconciliationInterval() { + return Optional.ofNullable(maxReconciliationInterval); + } + + @Override + public ConfigurationService getConfigurationService() { + return configurationService; + } + + @Override + @SuppressWarnings("unchecked") + public C getConfigurationFor(DependentResourceSpec spec) { + // first check if there's an overridden configuration at the controller level + var config = configurations.get(spec); + if (config == null) { + // if not, check the spec for configuration + config = spec.getConfiguration().orElse(null); + } + return (C) config; + } + + @Override + public String fieldManager() { + return fieldManager; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResourceClassResolver.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResourceClassResolver.java new file mode 100644 index 0000000000..b1d0af9263 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResourceClassResolver.java @@ -0,0 +1,10 @@ +package io.javaoperatorsdk.operator.api.config; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; + +public interface ResourceClassResolver { + +

Class

getPrimaryResourceClass( + Class> reconcilerClass); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Utils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Utils.java new file mode 100644 index 0000000000..3b6f94a025 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Utils.java @@ -0,0 +1,354 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.time.Instant; +import java.util.Arrays; +import java.util.Date; +import java.util.Optional; +import java.util.Properties; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +public class Utils { + + private static final Logger log = LoggerFactory.getLogger(Utils.class); + public static final String CHECK_CRD_ENV_KEY = "JAVA_OPERATOR_SDK_CHECK_CRD"; + public static final String DEBUG_THREAD_POOL_ENV_KEY = "JAVA_OPERATOR_SDK_DEBUG_THREAD_POOL"; + public static final String USE_MDC_ENV_KEY = "JAVA_OPERATOR_SDK_USE_MDC"; + public static final String GENERIC_PARAMETER_TYPE_ERROR_PREFIX = + "Couldn't retrieve generic parameter type from "; + + public static final Version VERSION = loadFromProperties(); + + /** + * Attempts to load version information from a properties file produced at build time, currently + * via the {@code git-commit-id-plugin} maven plugin. + * + * @return a {@link Version} object encapsulating the version information + */ + private static Version loadFromProperties() { + final var is = + Thread.currentThread().getContextClassLoader().getResourceAsStream("version.properties"); + + final var properties = new Properties(); + if (is != null) { + try { + properties.load(is); + } catch (IOException e) { + log.warn("Couldn't load version information: {}", e.getMessage()); + } + } else { + log.warn("Couldn't find version.properties file. Default version information will be used."); + } + + Date builtTime; + try { + String time = properties.getProperty("git.build.time"); + if (time != null) { + builtTime = Date.from(Instant.parse(time)); + } else { + builtTime = Date.from(Instant.EPOCH); + } + } catch (Exception e) { + log.debug("Couldn't parse git.build.time property", e); + builtTime = Date.from(Instant.EPOCH); + } + return new Version(properties.getProperty("git.commit.id.abbrev", "unknown"), builtTime); + } + + public static int ensureValid(int value, String description, int minValue) { + return ensureValid(value, description, minValue, minValue); + } + + public static int ensureValid(int value, String description, int minValue, int defaultValue) { + if (value < minValue) { + if (defaultValue < minValue) { + throw new IllegalArgumentException( + "Default value for " + description + " must be greater than " + minValue); + } + log.warn( + "Requested {} should be greater than {}. Requested: {}, using {}{} instead", + description, + minValue, + value, + defaultValue, + defaultValue == minValue ? "" : " (default)"); + value = defaultValue; + } + return value; + } + + @SuppressWarnings("unused") + // this is used in the Quarkus extension + public static boolean isValidateCustomResourcesEnvVarSet() { + return System.getProperty(CHECK_CRD_ENV_KEY) != null; + } + + public static boolean shouldCheckCRDAndValidateLocalModel() { + return getBooleanFromSystemPropsOrDefault(CHECK_CRD_ENV_KEY, false); + } + + public static boolean debugThreadPool() { + return getBooleanFromSystemPropsOrDefault(DEBUG_THREAD_POOL_ENV_KEY, false); + } + + public static boolean getBooleanFromSystemPropsOrDefault( + String propertyName, boolean defaultValue) { + var property = System.getProperty(propertyName); + if (property == null) { + return defaultValue; + } else { + property = property.trim().toLowerCase(); + return switch (property) { + case "true" -> true; + case "false" -> false; + default -> defaultValue; + }; + } + } + + public static Class getFirstTypeArgumentFromExtendedClass(Class clazz) { + return getTypeArgumentFromExtendedClassByIndex(clazz, 0); + } + + public static Class getTypeArgumentFromExtendedClassByIndex(Class clazz, int index) { + try { + Type type = clazz.getGenericSuperclass(); + return (Class) ((ParameterizedType) type).getActualTypeArguments()[index]; + } catch (Exception e) { + throw new RuntimeException( + GENERIC_PARAMETER_TYPE_ERROR_PREFIX + + clazz.getSimpleName() + + " because it doesn't extend a class that is parameterized with the type we want to" + + " retrieve", + e); + } + } + + public static Class getTypeArgumentFromHierarchyByIndex(Class clazz, int index) { + return getTypeArgumentFromHierarchyByIndex(clazz, null, index); + } + + public static Class getTypeArgumentFromHierarchyByIndex( + Class clazz, Class expectedImplementedInterface, int index) { + Class c = clazz; + while (!(c.getGenericSuperclass() instanceof ParameterizedType)) { + c = c.getSuperclass(); + } + Class actualTypeArgument = + (Class) ((ParameterizedType) c.getGenericSuperclass()).getActualTypeArguments()[index]; + if (expectedImplementedInterface != null + && !expectedImplementedInterface.isAssignableFrom(actualTypeArgument)) { + throw new IllegalArgumentException( + GENERIC_PARAMETER_TYPE_ERROR_PREFIX + + clazz.getName() + + "because it doesn't extend a class that is parametrized with the type that" + + " implements " + + expectedImplementedInterface.getSimpleName() + + ". Please provide the resource type in the constructor (e.g.," + + " super(Deployment.class)."); + } else if (expectedImplementedInterface == null && actualTypeArgument.equals(Object.class)) { + throw new IllegalArgumentException( + GENERIC_PARAMETER_TYPE_ERROR_PREFIX + + clazz.getName() + + " because it doesn't extend a class that is parametrized with the type we want to" + + " retrieve or because it's Object.class. Please provide the resource type in the " + + "constructor (e.g., super(Deployment.class)."); + } + return actualTypeArgument; + } + + public static Class getFirstTypeArgumentFromInterface( + Class clazz, Class expectedImplementedInterface) { + return getTypeArgumentFromInterfaceByIndex(clazz, expectedImplementedInterface, 0); + } + + public static Class getTypeArgumentFromInterfaceByIndex( + Class clazz, Class expectedImplementedInterface, int index) { + if (expectedImplementedInterface.isAssignableFrom(clazz)) { + final var genericInterfaces = clazz.getGenericInterfaces(); + + var target = extractType(clazz, expectedImplementedInterface, index, genericInterfaces); + if (target.isPresent()) { + return target.get(); + } + + // try the parent if we didn't find a parameter type on the current class + var parent = clazz.getSuperclass(); + if (!Object.class.equals(parent)) { + return getTypeArgumentFromInterfaceByIndex(parent, expectedImplementedInterface, index); + } + } + throw new IllegalArgumentException( + GENERIC_PARAMETER_TYPE_ERROR_PREFIX + + clazz.getSimpleName() + + " because it or its superclasses don't implement " + + expectedImplementedInterface.getSimpleName()); + } + + private static Optional> extractType( + Class clazz, Class expectedImplementedInterface, int index, Type[] genericInterfaces) { + Optional> target = Optional.empty(); + if (genericInterfaces.length > 0) { + // try to find the target interface among them + target = + Arrays.stream(genericInterfaces) + .filter( + type -> + type.getTypeName().startsWith(expectedImplementedInterface.getName()) + && type instanceof ParameterizedType) + .map(ParameterizedType.class::cast) + .findFirst() + .map( + t -> { + final Type argument = t.getActualTypeArguments()[index]; + if (argument instanceof Class) { + return (Class) argument; + } + // account for the case where the argument itself has parameters, which we will + // ignore + // and just return the raw type + if (argument instanceof ParameterizedType) { + final var rawType = ((ParameterizedType) argument).getRawType(); + if (rawType instanceof Class) { + return (Class) rawType; + } + } + throw new IllegalArgumentException( + clazz.getSimpleName() + + " implements " + + expectedImplementedInterface.getSimpleName() + + " but indirectly. Java type erasure doesn't allow to retrieve the" + + " generic type from it. Retrieved type was: " + + argument); + }); + } + return target; + } + + public static Class getFirstTypeArgumentFromSuperClassOrInterface( + Class clazz, Class expectedImplementedInterface) { + return getTypeArgumentFromSuperClassOrInterfaceByIndex(clazz, expectedImplementedInterface, 0); + } + + public static Class getTypeArgumentFromSuperClassOrInterfaceByIndex( + Class clazz, Class expectedImplementedInterface, int index) { + // first check super class if it exists + try { + final Class superclass = clazz.getSuperclass(); + if (!superclass.equals(Object.class)) { + try { + return getTypeArgumentFromExtendedClassByIndex(clazz, index); + } catch (Exception e) { + // try interfaces + try { + return getTypeArgumentFromInterfaceByIndex(clazz, expectedImplementedInterface, index); + } catch (Exception ex) { + // try on the parent + return getTypeArgumentFromSuperClassOrInterfaceByIndex( + superclass, expectedImplementedInterface, index); + } + } + } + return getTypeArgumentFromInterfaceByIndex(clazz, expectedImplementedInterface, index); + } catch (Exception e) { + throw new OperatorException(GENERIC_PARAMETER_TYPE_ERROR_PREFIX + clazz.getSimpleName(), e); + } + } + + public static T instantiateAndConfigureIfNeeded( + Class targetClass, + Class expectedType, + String context, + Configurator configurator) { + // if class to instantiate equals the expected interface, we cannot instantiate it so just + // return null as it means we passed on void-type default value + if (expectedType.equals(targetClass)) { + return null; + } + + try { + final var instance = getConstructor(targetClass).newInstance(); + + if (configurator != null) { + configurator.configure(instance); + } + + return instance; + } catch (InstantiationException + | IllegalAccessException + | InvocationTargetException + | IllegalStateException e) { + throw new OperatorException( + "Couldn't instantiate " + + expectedType.getSimpleName() + + " '" + + targetClass.getName() + + "'." + + (context != null ? " Context: " + context : ""), + e); + } + } + + public static Constructor getConstructor(Class targetClass) { + final Constructor constructor; + try { + constructor = targetClass.getDeclaredConstructor(); + } catch (NoSuchMethodException e) { + throw new IllegalStateException( + "Couldn't find a no-arg constructor for " + targetClass.getName(), e); + } + constructor.setAccessible(true); + return constructor; + } + + public static T instantiate( + Class toInstantiate, Class expectedType, String context) { + return instantiateAndConfigureIfNeeded(toInstantiate, expectedType, context, null); + } + + @FunctionalInterface + public interface Configurator { + void configure(T instance); + } + + @SuppressWarnings("rawtypes") + public static String contextFor( + ControllerConfiguration controllerConfiguration, + Class dependentType, + Class configurationAnnotation) { + return contextFor(controllerConfiguration.getName(), dependentType, configurationAnnotation); + } + + public static String contextFor(String reconcilerName) { + return contextFor(reconcilerName, null, null); + } + + @SuppressWarnings("rawtypes") + public static String contextFor( + String reconcilerName, + Class dependentType, + Class configurationAnnotation) { + final var annotationName = + configurationAnnotation != null + ? configurationAnnotation.getSimpleName() + : io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration.class + .getSimpleName(); + var context = "annotation: " + annotationName + ", "; + if (dependentType != null) { + context += "DependentResource: " + dependentType.getName() + ", "; + } + context += "reconciler: " + reconcilerName; + + return context; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Version.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Version.java new file mode 100644 index 0000000000..571e389ecc --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/Version.java @@ -0,0 +1,55 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.time.Instant; +import java.util.Date; + +/** A class encapsulating the version information associated with this SDK instance. */ +public class Version { + + public static final Version UNKNOWN = new Version("unknown", Date.from(Instant.EPOCH)); + private final String commit; + private final Date builtTime; + + public Version(String commit, Date builtTime) { + this.commit = commit; + this.builtTime = builtTime; + } + + /** + * Returns the SDK project version + * + * @return the SDK project version + */ + public String getSdkVersion() { + return Versions.JOSDK; + } + + /** + * Returns the git commit id associated with this SDK instance + * + * @return the git commit id + */ + public String getCommit() { + return commit; + } + + /** + * Returns the date at which this SDK instance was built + * + * @return the build time at which this SDK instance was built or the date corresponding to {@link + * java.time.Instant#EPOCH} if the built time couldn't be retrieved + */ + public Date getBuiltTime() { + return builtTime; + } + + /** + * Returns the version of the Fabric8 Kubernetes Client being used by this version of the SDK + * + * @return the Fabric8 Kubernetes Client version + */ + @SuppressWarnings("unused") + public String getKubernetesClientVersion() { + return Versions.KUBERNETES_CLIENT; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/ConfigurationConverter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/ConfigurationConverter.java new file mode 100644 index 0000000000..beebc16239 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/ConfigurationConverter.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.api.config.dependent; + +import java.lang.annotation.Annotation; + +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; + +public interface ConfigurationConverter { + + C configFrom( + A configAnnotation, + DependentResourceSpec spec, + ControllerConfiguration parentConfiguration); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/Configured.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/Configured.java new file mode 100644 index 0000000000..db8c6f6db3 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/Configured.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.api.config.dependent; + +import java.lang.annotation.Annotation; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface Configured { + + Class by(); + + Class with(); + + @SuppressWarnings("rawtypes") + Class converter(); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/DependentResourceConfigurationResolver.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/DependentResourceConfigurationResolver.java new file mode 100644 index 0000000000..837ff7fbb0 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/DependentResourceConfigurationResolver.java @@ -0,0 +1,172 @@ +package io.javaoperatorsdk.operator.api.config.dependent; + +import java.lang.annotation.Annotation; +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.Utils; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +@SuppressWarnings({"rawtypes", "unchecked"}) +public class DependentResourceConfigurationResolver { + + private DependentResourceConfigurationResolver() {} + + private static final Map, ConverterAnnotationPair> converters = + new HashMap<>(); + private static final Map, ConfigurationConverter> + knownConverters = new HashMap<>(); + + public static > void configureSpecFromConfigured( + DependentResourceSpec spec, + C parentConfiguration, + Class dependentResourceClass) { + var converterAnnotationPair = converters.get(dependentResourceClass); + + Annotation configAnnotation; + if (converterAnnotationPair == null) { + var configuredClassPair = getConfigured(dependentResourceClass); + if (configuredClassPair == null) { + return; + } + + // check if we already have a converter registered for the found Configured annotated class + converterAnnotationPair = converters.get(configuredClassPair.annotatedClass); + if (converterAnnotationPair == null) { + final var configured = configuredClassPair.configured; + converterAnnotationPair = + getOrCreateConverter( + dependentResourceClass, + parentConfiguration, + configured.converter(), + configured.by()); + } else { + // only register the converter pair for this dependent resource class as well + converters.put(dependentResourceClass, converterAnnotationPair); + } + } + + // find the associated configuration annotation + configAnnotation = + dependentResourceClass.getAnnotation(converterAnnotationPair.annotationClass); + final var converter = converterAnnotationPair.converter; + + // always called even if the annotation is null so that implementations can provide default + // values + final var config = converter.configFrom(configAnnotation, spec, parentConfiguration); + spec.setNullableConfiguration(config); + } + + private static ConfiguredClassPair getConfigured( + Class dependentResourceClass) { + Class currentClass = dependentResourceClass; + Configured configured; + ConfiguredClassPair result = null; + while (DependentResource.class.isAssignableFrom(currentClass)) { + configured = currentClass.getAnnotation(Configured.class); + if (configured != null) { + result = new ConfiguredClassPair(configured, currentClass); + break; + } + currentClass = (Class) currentClass.getSuperclass(); + } + return result; + } + + private static > + ConverterAnnotationPair getOrCreateConverter( + Class dependentResourceClass, + C parentConfiguration, + Class converterClass, + Class annotationClass) { + var converterPair = converters.get(dependentResourceClass); + if (converterPair == null) { + // only instantiate a new converter if we haven't done so already for this converter type + var converter = knownConverters.get(converterClass); + if (converter == null) { + converter = + Utils.instantiate( + converterClass, + ConfigurationConverter.class, + Utils.contextFor(parentConfiguration, dependentResourceClass, Configured.class)); + knownConverters.put(converterClass, converter); + } + // record dependent class - converter association for faster future retrieval + converterPair = new ConverterAnnotationPair(converter, annotationClass); + converters.put(dependentResourceClass, converterPair); + } + return converterPair; + } + + static ConfigurationConverter getConverter( + Class dependentResourceClass) { + final var converterAnnotationPair = converters.get(dependentResourceClass); + return converterAnnotationPair != null ? converterAnnotationPair.converter : null; + } + + @SuppressWarnings("unused") + public static void registerConverter( + Class dependentResourceClass, ConfigurationConverter converter) { + var configured = getConfigured(dependentResourceClass); + if (configured == null) { + throw new IllegalArgumentException( + "There is no @" + + Configured.class.getSimpleName() + + " annotation on " + + dependentResourceClass.getName() + + " or its superclasses and thus doesn't need to be associated with a converter"); + } + + // find the associated configuration annotation + final var toRegister = new ConverterAnnotationPair(converter, configured.configured.by()); + final Class converterClass = converter.getClass(); + converters.put(dependentResourceClass, toRegister); + + // also register the Configured-annotated class if not the one we're registering + if (!dependentResourceClass.equals(configured.annotatedClass)) { + converters.put(configured.annotatedClass, toRegister); + } + + knownConverters.put(converterClass, converter); + } + + /** To support independent unit tests */ + public static void clear() { + converters.clear(); + knownConverters.clear(); + } + + private static class ConfiguredClassPair { + private final Configured configured; + private final Class annotatedClass; + + private ConfiguredClassPair( + Configured configured, Class annotatedClass) { + this.configured = configured; + this.annotatedClass = annotatedClass; + } + + @Override + public String toString() { + return annotatedClass.getName() + " -> " + configured; + } + } + + private static class ConverterAnnotationPair { + private final ConfigurationConverter converter; + private final Class annotationClass; + + private ConverterAnnotationPair( + ConfigurationConverter converter, Class annotationClass) { + this.converter = converter; + this.annotationClass = annotationClass; + } + + @Override + public String toString() { + return converter.toString() + " -> " + annotationClass.getName(); + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/DependentResourceSpec.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/DependentResourceSpec.java new file mode 100644 index 0000000000..8e79571e73 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/dependent/DependentResourceSpec.java @@ -0,0 +1,111 @@ +package io.javaoperatorsdk.operator.api.config.dependent; + +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class DependentResourceSpec { + + private final Class> dependentResourceClass; + private final String name; + private final Set dependsOn; + private final Condition readyCondition; + private final Condition reconcileCondition; + private final Condition deletePostCondition; + private final Condition activationCondition; + private final String useEventSourceWithName; + private C nullableConfiguration; + + public DependentResourceSpec( + Class> dependentResourceClass, + String name, + Set dependsOn, + Condition readyCondition, + Condition reconcileCondition, + Condition deletePostCondition, + Condition activationCondition, + String useEventSourceWithName) { + this.dependentResourceClass = dependentResourceClass; + this.name = name; + this.dependsOn = dependsOn; + this.readyCondition = readyCondition; + this.reconcileCondition = reconcileCondition; + this.deletePostCondition = deletePostCondition; + this.activationCondition = activationCondition; + this.useEventSourceWithName = useEventSourceWithName; + } + + public Class> getDependentResourceClass() { + return dependentResourceClass; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return "DependentResourceSpec{ name='" + + name + + "', type=" + + getDependentResourceClass().getCanonicalName() + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DependentResourceSpec that = (DependentResourceSpec) o; + return name.equals(that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + public Set getDependsOn() { + return dependsOn; + } + + @SuppressWarnings("rawtypes") + public Condition getReadyCondition() { + return readyCondition; + } + + @SuppressWarnings("rawtypes") + public Condition getReconcileCondition() { + return reconcileCondition; + } + + @SuppressWarnings("rawtypes") + public Condition getDeletePostCondition() { + return deletePostCondition; + } + + @SuppressWarnings("rawtypes") + public Condition getActivationCondition() { + return activationCondition; + } + + public Optional getUseEventSourceWithName() { + return Optional.ofNullable(useEventSourceWithName); + } + + public Optional getConfiguration() { + return Optional.ofNullable(nullableConfiguration); + } + + protected void setNullableConfiguration(C configuration) { + this.nullableConfiguration = configuration; + } +} 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 new file mode 100644 index 0000000000..80a025009d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java @@ -0,0 +1,116 @@ +package io.javaoperatorsdk.operator.api.config.informer; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.fabric8.kubernetes.client.informers.cache.ItemStore; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.processing.event.source.cache.BoundedItemStore; +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.OnDeleteFilter; +import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; + +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; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface Informer { + + String name() default NO_VALUE_SET; + + /** + * Specified which namespaces the associated informer monitors for custom resources events. If no + * namespace is specified then which namespaces the informer will monitor will depend on the + * context in which the informer is configured: + * + *

+ * + * You can set a list of namespaces or use the following constants: + * + *
    + *
  • {@link Constants#WATCH_ALL_NAMESPACES} + *
  • {@link Constants#WATCH_CURRENT_NAMESPACE} + *
  • {@link Constants#SAME_AS_CONTROLLER} + *
+ * + * @return the array of namespaces the associated informer monitors + */ + String[] namespaces() default {Constants.SAME_AS_CONTROLLER}; + + /** + * Optional label selector used to identify the set of custom resources the associated informer + * will act upon. The label selector can be made of multiple comma separated requirements that + * acts as a logical AND operator. + * + * @return the label selector + */ + String labelSelector() default NO_VALUE_SET; + + /** + * Optional {@link OnAddFilter} to filter add events sent to the associated informer + * + * @return the {@link OnAddFilter} filter implementation to use, defaulting to the interface + * itself if no value is set + */ + Class onAddFilter() default OnAddFilter.class; + + /** + * Optional {@link OnUpdateFilter} to filter update events sent to the associated informer + * + * @return the {@link OnUpdateFilter} filter implementation to use, defaulting to the interface + * itself if no value is set + */ + Class onUpdateFilter() default OnUpdateFilter.class; + + /** + * Optional {@link OnDeleteFilter} to filter delete events sent to the associated informer + * + * @return the {@link OnDeleteFilter} filter implementation to use, defaulting to the interface + * itself if no value is set + */ + Class onDeleteFilter() default OnDeleteFilter.class; + + /** + * Optional {@link GenericFilter} to filter events sent to the associated informer + * + * @return the {@link GenericFilter} filter implementation to use, defaulting to the interface + * itself if no value is set + */ + Class genericFilter() default GenericFilter.class; + + /** + * Set that in case of a runtime controller namespace changes, the informer should also follow the + * new namespace set. + */ + boolean followControllerNamespaceChanges() default DEFAULT_FOLLOW_CONTROLLER_NAMESPACE_CHANGES; + + /** + * Replaces the item store used by the informer for the associated primary resource controller. + * See underlying
+ * method in fabric8 client informer implementation. + * + *

The main goal, is to be able to use limited caches or provide any custom implementation. + * + *

See {@link BoundedItemStore} and CaffeinBoundedCache + * + * @return the class of the {@link ItemStore} implementation to use + */ + Class itemStore() default ItemStore.class; + + /** + * The maximum amount of items to return for a single list call when starting the primary resource + * related informers. If this is a not null it will result in paginating for the initial load of + * the informer cache. + */ + long informerListLimit() default NO_LONG_VALUE_SET; +} 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 new file mode 100644 index 0000000000..958a2a7a6f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java @@ -0,0 +1,428 @@ +package io.javaoperatorsdk.operator.api.config.informer; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +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.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.Utils; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.processing.event.source.cache.BoundedItemStore; +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.OnDeleteFilter; +import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.*; + +@SuppressWarnings("unused") +public class InformerConfiguration { + private final Builder builder = new Builder(); + private final Class resourceClass; + private final String resourceTypeName; + private String name; + private Set namespaces; + private Boolean followControllerNamespaceChanges; + private String labelSelector; + private OnAddFilter onAddFilter; + private OnUpdateFilter onUpdateFilter; + private OnDeleteFilter onDeleteFilter; + private GenericFilter genericFilter; + private ItemStore itemStore; + private Long informerListLimit; + + protected InformerConfiguration( + Class resourceClass, + String name, + Set namespaces, + boolean followControllerNamespaceChanges, + String labelSelector, + OnAddFilter onAddFilter, + OnUpdateFilter onUpdateFilter, + OnDeleteFilter onDeleteFilter, + GenericFilter genericFilter, + ItemStore itemStore, + Long informerListLimit) { + this(resourceClass); + this.name = name; + this.namespaces = namespaces; + this.followControllerNamespaceChanges = followControllerNamespaceChanges; + this.labelSelector = labelSelector; + this.onAddFilter = onAddFilter; + this.onUpdateFilter = onUpdateFilter; + this.onDeleteFilter = onDeleteFilter; + this.genericFilter = genericFilter; + this.itemStore = itemStore; + this.informerListLimit = informerListLimit; + } + + private InformerConfiguration(Class resourceClass) { + this.resourceClass = resourceClass; + this.resourceTypeName = + resourceClass.isAssignableFrom(GenericKubernetesResource.class) + // in general this is irrelevant now for secondary resources it is used just by + // controller + // where GenericKubernetesResource now does not apply + ? GenericKubernetesResource.class.getSimpleName() + : ReconcilerUtils.getResourceTypeName(resourceClass); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + public static InformerConfiguration.Builder builder( + Class resourceClass) { + return new InformerConfiguration(resourceClass).builder; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + public static InformerConfiguration.Builder builder( + InformerConfiguration original) { + return new InformerConfiguration( + original.resourceClass, + original.name, + original.namespaces, + original.followControllerNamespaceChanges, + original.labelSelector, + original.onAddFilter, + original.onUpdateFilter, + original.onDeleteFilter, + original.genericFilter, + original.itemStore, + original.informerListLimit) + .builder; + } + + public static String ensureValidLabelSelector(String labelSelector) { + // might want to implement validation here? + return labelSelector; + } + + public static boolean allNamespacesWatched(Set namespaces) { + failIfNotValid(namespaces); + return DEFAULT_NAMESPACES_SET.equals(namespaces); + } + + public static boolean currentNamespaceWatched(Set namespaces) { + failIfNotValid(namespaces); + return WATCH_CURRENT_NAMESPACE_SET.equals(namespaces); + } + + public static void failIfNotValid(Set namespaces) { + if (namespaces != null && !namespaces.isEmpty()) { + final var present = + namespaces.contains(WATCH_CURRENT_NAMESPACE) || namespaces.contains(WATCH_ALL_NAMESPACES); + if (!present || namespaces.size() == 1) { + return; + } + } + throw new IllegalArgumentException( + "Must specify namespaces. To watch all namespaces, use only '" + + WATCH_ALL_NAMESPACES + + "'. To watch only the namespace in which the operator is deployed, use only '" + + WATCH_CURRENT_NAMESPACE + + "'"); + } + + public static Set ensureValidNamespaces(Collection namespaces) { + if (namespaces != null && !namespaces.isEmpty()) { + return namespaces.stream().map(String::trim).collect(Collectors.toSet()); + } else { + return Constants.DEFAULT_NAMESPACES_SET; + } + } + + public static boolean inheritsNamespacesFromController(Set namespaces) { + return SAME_AS_CONTROLLER_NAMESPACES_SET.equals(namespaces); + } + + public Class getResourceClass() { + return resourceClass; + } + + public String getResourceTypeName() { + return resourceTypeName; + } + + public String getName() { + return name; + } + + public Set getNamespaces() { + return namespaces; + } + + public boolean watchAllNamespaces() { + return InformerConfiguration.allNamespacesWatched(getNamespaces()); + } + + public boolean watchCurrentNamespace() { + return InformerConfiguration.currentNamespaceWatched(getNamespaces()); + } + + public boolean inheritsNamespacesFromController() { + return inheritsNamespacesFromController(getNamespaces()); + } + + /** + * Computes the effective namespaces based on the set specified by the user, in particular + * retrieves the current namespace from the client when the user specified that they wanted to + * watch the current namespace only. + * + * @return a Set of namespace names the associated controller will watch + */ + public Set getEffectiveNamespaces(ControllerConfiguration controllerConfiguration) { + if (inheritsNamespacesFromController()) { + return controllerConfiguration.getEffectiveNamespaces(); + } + + var targetNamespaces = getNamespaces(); + if (watchCurrentNamespace()) { + final String namespace = + controllerConfiguration + .getConfigurationService() + .getKubernetesClient() + .getConfiguration() + .getNamespace(); + if (namespace == null) { + throw new OperatorException( + "Couldn't retrieve the currently connected namespace. Make sure it's correctly set in" + + " your ~/.kube/config file, using, e.g. 'kubectl config set-context --namespace='"); + } + targetNamespaces = Collections.singleton(namespace); + } + return targetNamespaces; + } + + /** + * Used in case the watched namespaces are changed dynamically, thus when operator is running (See + * {@link io.javaoperatorsdk.operator.RegisteredController}). If true, changing the target + * namespaces of a controller would result to change target namespaces for the + * InformerEventSource. + * + * @return if namespace changes should be followed + */ + public boolean getFollowControllerNamespaceChanges() { + return followControllerNamespaceChanges; + } + + /** + * Retrieves the label selector that is used to filter which resources are actually watched by the + * associated informer. See the official documentation on the topic for + * more details on syntax. + * + * @return the label selector filtering watched resources + */ + public String getLabelSelector() { + return labelSelector; + } + + public OnAddFilter getOnAddFilter() { + return onAddFilter; + } + + public OnUpdateFilter getOnUpdateFilter() { + return onUpdateFilter; + } + + public OnDeleteFilter getOnDeleteFilter() { + return onDeleteFilter; + } + + public GenericFilter getGenericFilter() { + return genericFilter; + } + + /** + * Replaces the item store in informer. See underlying method + * in fabric8 client informer implementation. + * + *

The main goal, is to be able to use limited caches or provide any custom implementation. + * + *

See {@link BoundedItemStore} and CaffeineBoundedCache + * + * @return Optional {@link ItemStore} implementation. If present this item store will be used by + * the informers. + */ + public ItemStore getItemStore() { + return itemStore; + } + + /** + * The maximum amount of items to return for a single list call when starting an informer. If this + * is a not null it will result in paginating for the initial load of the informer cache. + */ + public Long getInformerListLimit() { + return informerListLimit; + } + + @SuppressWarnings("UnusedReturnValue") + public class Builder { + + /** For internal usage only. Use {@link #build()} method for building for InformerEventSource */ + public InformerConfiguration buildForController() { + // if the informer config uses the default "same as controller" value, reset the namespaces to + // the default set for controllers + if (namespaces == null + || namespaces.isEmpty() + || inheritsNamespacesFromController(namespaces)) { + namespaces = Constants.DEFAULT_NAMESPACES_SET; + } + // to avoid potential NPE + followControllerNamespaceChanges = false; + return InformerConfiguration.this; + } + + /** Build for InformerEventSource */ + public InformerConfiguration build() { + if (namespaces == null || namespaces.isEmpty()) { + namespaces = Constants.SAME_AS_CONTROLLER_NAMESPACES_SET; + } + if (followControllerNamespaceChanges == null) { + followControllerNamespaceChanges = DEFAULT_FOLLOW_CONTROLLER_NAMESPACE_CHANGES; + } + return InformerConfiguration.this; + } + + @SuppressWarnings({"unchecked"}) + public InformerConfiguration.Builder initFromAnnotation( + Informer informerConfig, String context) { + if (informerConfig != null) { + + // override default name if more specific one is provided + if (!Constants.NO_VALUE_SET.equals(informerConfig.name())) { + withName(informerConfig.name()); + } + + var namespaces = Set.of(informerConfig.namespaces()); + withNamespaces(namespaces); + + final var fromAnnotation = informerConfig.labelSelector(); + var labelSelector = Constants.NO_VALUE_SET.equals(fromAnnotation) ? null : fromAnnotation; + withLabelSelector(labelSelector); + + withOnAddFilter( + Utils.instantiate(informerConfig.onAddFilter(), OnAddFilter.class, context)); + + withOnUpdateFilter( + Utils.instantiate(informerConfig.onUpdateFilter(), OnUpdateFilter.class, context)); + + withOnDeleteFilter( + Utils.instantiate(informerConfig.onDeleteFilter(), OnDeleteFilter.class, context)); + + withGenericFilter( + Utils.instantiate(informerConfig.genericFilter(), GenericFilter.class, context)); + + withFollowControllerNamespacesChanges(informerConfig.followControllerNamespaceChanges()); + + withItemStore(Utils.instantiate(informerConfig.itemStore(), ItemStore.class, context)); + + final var informerListLimitValue = informerConfig.informerListLimit(); + final var informerListLimit = + informerListLimitValue == Constants.NO_LONG_VALUE_SET ? null : informerListLimitValue; + withInformerListLimit(informerListLimit); + } + return this; + } + + public Builder withName(String name) { + InformerConfiguration.this.name = name; + return this; + } + + public Builder withNamespaces(Set namespaces) { + InformerConfiguration.this.namespaces = ensureValidNamespaces(namespaces); + return this; + } + + public Set namespaces() { + return Set.copyOf(namespaces); + } + + /** + * Sets the initial set of namespaces to watch (typically extracted from the parent {@link + * io.javaoperatorsdk.operator.processing.Controller}'s configuration), specifying whether + * changes made to the parent controller configured namespaces should be tracked or not. + * + * @param namespaces the initial set of namespaces to watch + * @param followChanges {@code true} to follow the changes made to the parent controller + * namespaces, {@code false} otherwise + * @return the builder instance so that calls can be chained fluently + */ + public Builder withNamespaces(Set namespaces, boolean followChanges) { + withNamespaces(namespaces).withFollowControllerNamespacesChanges(followChanges); + return this; + } + + public Builder withNamespacesInheritedFromController() { + withNamespaces(SAME_AS_CONTROLLER_NAMESPACES_SET); + return this; + } + + public Builder withWatchAllNamespaces() { + withNamespaces(WATCH_ALL_NAMESPACE_SET); + return this; + } + + public Builder withWatchCurrentNamespace() { + withNamespaces(WATCH_CURRENT_NAMESPACE_SET); + return this; + } + + /** + * Whether the associated informer should track changes made to the parent {@link + * io.javaoperatorsdk.operator.processing.Controller}'s namespaces configuration. + * + * @param followChanges {@code true} to reconfigure the associated informer when the parent + * controller's namespaces are reconfigured, {@code false} otherwise + * @return the builder instance so that calls can be chained fluently + */ + public Builder withFollowControllerNamespacesChanges(boolean followChanges) { + InformerConfiguration.this.followControllerNamespaceChanges = followChanges; + return this; + } + + public Builder withLabelSelector(String labelSelector) { + InformerConfiguration.this.labelSelector = ensureValidLabelSelector(labelSelector); + return this; + } + + public Builder withOnAddFilter(OnAddFilter onAddFilter) { + InformerConfiguration.this.onAddFilter = onAddFilter; + return this; + } + + public Builder withOnUpdateFilter(OnUpdateFilter onUpdateFilter) { + InformerConfiguration.this.onUpdateFilter = onUpdateFilter; + return this; + } + + public Builder withOnDeleteFilter(OnDeleteFilter onDeleteFilter) { + InformerConfiguration.this.onDeleteFilter = onDeleteFilter; + return this; + } + + public Builder withGenericFilter(GenericFilter genericFilter) { + InformerConfiguration.this.genericFilter = genericFilter; + return this; + } + + public Builder withItemStore(ItemStore itemStore) { + InformerConfiguration.this.itemStore = itemStore; + return this; + } + + public Builder withInformerListLimit(Long informerListLimit) { + InformerConfiguration.this.informerListLimit = informerListLimit; + 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 new file mode 100644 index 0000000000..2369d5f523 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java @@ -0,0 +1,309 @@ +package io.javaoperatorsdk.operator.api.config.informer; + +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.informers.cache.ItemStore; +import io.javaoperatorsdk.operator.api.config.Informable; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; +import io.javaoperatorsdk.operator.processing.event.source.PrimaryToSecondaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +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.OnDeleteFilter; +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.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; + +public interface InformerEventSourceConfiguration extends Informable { + + static Builder from( + Class resourceClass, Class primaryResourceClass) { + return new Builder<>(resourceClass, primaryResourceClass); + } + + static Builder from( + GroupVersionKind groupVersionKind, Class primaryResourceClass) { + return new Builder<>(groupVersionKind, primaryResourceClass); + } + + /** + * Used in case the watched namespaces are changed dynamically, thus when operator is running (See + * {@link io.javaoperatorsdk.operator.RegisteredController}). If true, changing the target + * namespaces of a controller would result to change target namespaces for the + * InformerEventSource. + * + * @return if namespace changes should be followed + */ + default boolean followControllerNamespaceChanges() { + return getInformerConfig().getFollowControllerNamespaceChanges(); + } + + /** + * Returns the configured {@link SecondaryToPrimaryMapper} which will allow JOSDK to identify + * which secondary resources are associated with a given primary resource in cases where there is + * no explicit reference to the primary resource (e.g. using owner references) in the associated + * secondary resources. + * + * @return the configured {@link SecondaryToPrimaryMapper} + * @see SecondaryToPrimaryMapper for more explanations on when using such a mapper is useful / + * needed + */ + SecondaryToPrimaryMapper getSecondaryToPrimaryMapper(); + +

PrimaryToSecondaryMapper

getPrimaryToSecondaryMapper(); + + Optional getGroupVersionKind(); + + default String name() { + return getInformerConfig().getName(); + } + + /** + * Optional, specific kubernetes client, typically to connect to a different cluster than the rest + * of the operator. Note that this is solely for multi cluster support. + */ + default Optional getKubernetesClient() { + return Optional.empty(); + } + + class DefaultInformerEventSourceConfiguration + implements InformerEventSourceConfiguration { + private final PrimaryToSecondaryMapper primaryToSecondaryMapper; + private final SecondaryToPrimaryMapper secondaryToPrimaryMapper; + private final GroupVersionKind groupVersionKind; + private final InformerConfiguration informerConfig; + private final KubernetesClient kubernetesClient; + + protected DefaultInformerEventSourceConfiguration( + GroupVersionKind groupVersionKind, + PrimaryToSecondaryMapper primaryToSecondaryMapper, + SecondaryToPrimaryMapper secondaryToPrimaryMapper, + InformerConfiguration informerConfig, + KubernetesClient kubernetesClient) { + this.informerConfig = Objects.requireNonNull(informerConfig); + this.groupVersionKind = groupVersionKind; + this.primaryToSecondaryMapper = primaryToSecondaryMapper; + this.secondaryToPrimaryMapper = secondaryToPrimaryMapper; + this.kubernetesClient = kubernetesClient; + } + + @Override + public InformerConfiguration getInformerConfig() { + return informerConfig; + } + + @Override + public SecondaryToPrimaryMapper getSecondaryToPrimaryMapper() { + return secondaryToPrimaryMapper; + } + + @Override + @SuppressWarnings("unchecked") + public

PrimaryToSecondaryMapper

getPrimaryToSecondaryMapper() { + return (PrimaryToSecondaryMapper

) primaryToSecondaryMapper; + } + + @Override + public Optional getGroupVersionKind() { + return Optional.ofNullable(groupVersionKind); + } + + @Override + public Optional getKubernetesClient() { + return Optional.ofNullable(kubernetesClient); + } + } + + @SuppressWarnings({"unused", "UnusedReturnValue"}) + class Builder { + + private final Class resourceClass; + private final GroupVersionKind groupVersionKind; + private final Class primaryResourceClass; + private final InformerConfiguration.Builder config; + private String name; + private PrimaryToSecondaryMapper primaryToSecondaryMapper; + private SecondaryToPrimaryMapper secondaryToPrimaryMapper; + private KubernetesClient kubernetesClient; + + private Builder(Class resourceClass, Class primaryResourceClass) { + this(resourceClass, primaryResourceClass, null); + } + + @SuppressWarnings("unchecked") + private Builder( + GroupVersionKind groupVersionKind, Class primaryResourceClass) { + this((Class) GenericKubernetesResource.class, primaryResourceClass, groupVersionKind); + } + + private Builder( + Class resourceClass, + Class primaryResourceClass, + GroupVersionKind groupVersionKind) { + this.resourceClass = resourceClass; + this.groupVersionKind = groupVersionKind; + this.primaryResourceClass = primaryResourceClass; + this.config = InformerConfiguration.builder(resourceClass); + } + + public Builder withName(String name) { + this.name = name; + config.withName(name); + return this; + } + + public

Builder withPrimaryToSecondaryMapper( + PrimaryToSecondaryMapper

primaryToSecondaryMapper) { + this.primaryToSecondaryMapper = primaryToSecondaryMapper; + return this; + } + + public Builder withSecondaryToPrimaryMapper( + SecondaryToPrimaryMapper secondaryToPrimaryMapper) { + this.secondaryToPrimaryMapper = secondaryToPrimaryMapper; + return this; + } + + /** + * Use this is case want to create an InformerEventSource that handles resources from different + * cluster. + */ + public Builder withKubernetesClient(KubernetesClient kubernetesClient) { + this.kubernetesClient = kubernetesClient; + return this; + } + + public String getName() { + return name; + } + + public SecondaryToPrimaryMapper getSecondaryToPrimaryMapper() { + return secondaryToPrimaryMapper; + } + + public Builder withNamespaces(Set namespaces) { + config.withNamespaces(namespaces); + return this; + } + + /** + * @since 5.1.1 + */ + public Builder withNamespaces(String... namespaces) { + config.withNamespaces(Set.of(namespaces)); + return this; + } + + public Builder withNamespacesInheritedFromController() { + withNamespaces(SAME_AS_CONTROLLER_NAMESPACES_SET); + return this; + } + + public Builder withWatchAllNamespaces() { + withNamespaces(WATCH_ALL_NAMESPACE_SET); + return this; + } + + public Builder withWatchCurrentNamespace() { + withNamespaces(WATCH_CURRENT_NAMESPACE_SET); + return this; + } + + /** + * Whether the associated informer should track changes made to the parent {@link + * io.javaoperatorsdk.operator.processing.Controller}'s namespaces configuration. + * + * @param followChanges {@code true} to reconfigure the associated informer when the parent + * controller's namespaces are reconfigured, {@code false} otherwise + * @return the builder instance so that calls can be chained fluently + */ + public Builder withFollowControllerNamespacesChanges(boolean followChanges) { + config.withFollowControllerNamespacesChanges(followChanges); + return this; + } + + public Builder withLabelSelector(String labelSelector) { + config.withLabelSelector(labelSelector); + return this; + } + + public Builder withOnAddFilter(OnAddFilter onAddFilter) { + config.withOnAddFilter(onAddFilter); + return this; + } + + public Builder withOnUpdateFilter(OnUpdateFilter onUpdateFilter) { + config.withOnUpdateFilter(onUpdateFilter); + return this; + } + + public Builder withOnDeleteFilter(OnDeleteFilter onDeleteFilter) { + config.withOnDeleteFilter(onDeleteFilter); + return this; + } + + public Builder withGenericFilter(GenericFilter genericFilter) { + config.withGenericFilter(genericFilter); + return this; + } + + public Builder withItemStore(ItemStore itemStore) { + config.withItemStore(itemStore); + return this; + } + + public Builder withInformerListLimit(Long informerListLimit) { + config.withInformerListLimit(informerListLimit); + return this; + } + + public void updateFrom(InformerConfiguration informerConfig) { + if (informerConfig != null) { + final var informerConfigName = informerConfig.getName(); + if (informerConfigName != null) { + this.name = informerConfigName; + } + config + .withNamespaces(informerConfig.getNamespaces()) + .withFollowControllerNamespacesChanges( + informerConfig.getFollowControllerNamespaceChanges()) + .withLabelSelector(informerConfig.getLabelSelector()) + .withItemStore(informerConfig.getItemStore()) + .withOnAddFilter(informerConfig.getOnAddFilter()) + .withOnUpdateFilter(informerConfig.getOnUpdateFilter()) + .withOnDeleteFilter(informerConfig.getOnDeleteFilter()) + .withGenericFilter(informerConfig.getGenericFilter()) + .withInformerListLimit(informerConfig.getInformerListLimit()); + } + } + + public InformerEventSourceConfiguration build() { + if (groupVersionKind != null + && !GenericKubernetesResource.class.isAssignableFrom(resourceClass)) { + throw new IllegalStateException( + "If GroupVersionKind is set the resource type must be" + + " GenericKubernetesDependentResource"); + } + + return new DefaultInformerEventSourceConfiguration<>( + groupVersionKind, + primaryToSecondaryMapper, + Objects.requireNonNullElse( + secondaryToPrimaryMapper, + Mappers.fromOwnerReferences( + HasMetadata.getApiVersion(primaryResourceClass), + HasMetadata.getKind(primaryResourceClass), + false)), + config.build(), + kubernetesClient); + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/workflow/WorkflowSpec.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/workflow/WorkflowSpec.java new file mode 100644 index 0000000000..72d50f8050 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/workflow/WorkflowSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.api.config.workflow; + +import java.util.List; + +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; + +public interface WorkflowSpec { + + @SuppressWarnings("rawtypes") + List getDependentResourceSpecs(); + + boolean isExplicitInvocation(); + + boolean handleExceptionsInReconciler(); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java new file mode 100644 index 0000000000..3e3c834c3e --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java @@ -0,0 +1,176 @@ +package io.javaoperatorsdk.operator.api.monitoring; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +/** + * An interface that metrics providers can implement and that the SDK will call at different times + * of its execution cycle. + */ +public interface Metrics { + + /** The default Metrics provider: a no-operation implementation. */ + Metrics NOOP = new Metrics() {}; + + /** + * Do initialization if necessary; + * + * @param controller callback + */ + default void controllerRegistered(Controller controller) {} + + /** + * Called when an event has been accepted by the SDK from an event source, which would result in + * potentially triggering the associated Reconciler. + * + * @param event the event + * @param metadata metadata associated with the resource being processed + */ + default void receivedEvent(Event event, Map metadata) {} + + /** + * Called right before a resource is dispatched to the ExecutorService for reconciliation. + * + * @param resource the associated with the resource + * @param retryInfo the current retry state information for the reconciliation request + * @param metadata metadata associated with the resource being processed + */ + default void reconcileCustomResource( + HasMetadata resource, RetryInfo retryInfo, Map metadata) {} + + /** + * Called when a precedent reconciliation for the resource associated with the specified {@link + * ResourceID} resulted in the provided exception, resulting in a retry of the reconciliation. + * + * @param resource the {@link ResourceID} associated with the resource being processed + * @param exception the exception that caused the failed reconciliation resulting in a retry + * @param metadata metadata associated with the resource being processed + */ + default void failedReconciliation( + HasMetadata resource, Exception exception, Map metadata) {} + + default void reconciliationExecutionStarted(HasMetadata resource, Map metadata) {} + + default void reconciliationExecutionFinished( + HasMetadata resource, Map metadata) {} + + /** + * Called when the resource associated with the specified {@link ResourceID} has been successfully + * deleted and the clean-up performed by the associated reconciler is finished. + * + * @param resourceID the {@link ResourceID} associated with the resource being processed + * @param metadata metadata associated with the resource being processed + */ + default void cleanupDoneFor(ResourceID resourceID, Map metadata) {} + + /** + * Called when the {@link + * io.javaoperatorsdk.operator.api.reconciler.Reconciler#reconcile(HasMetadata, Context)} method + * of the Reconciler associated with the resource associated with the specified {@link ResourceID} + * has sucessfully finished. + * + * @param resource the {@link ResourceID} associated with the resource being processed + * @param metadata metadata associated with the resource being processed + */ + default void finishedReconciliation(HasMetadata resource, Map metadata) {} + + /** + * Encapsulates the information about a controller execution i.e. a call to either {@link + * io.javaoperatorsdk.operator.api.reconciler.Reconciler#reconcile(HasMetadata, Context)} or + * {@link io.javaoperatorsdk.operator.api.reconciler.Cleaner#cleanup(HasMetadata, Context)}. Note + * that instances are automatically created for you by the SDK and passed to your Metrics + * implementation at the appropriate time to the {@link + * #timeControllerExecution(ControllerExecution)} method. + * + * @param the outcome type associated with the controller execution. Currently, one of {@link + * io.javaoperatorsdk.operator.api.reconciler.UpdateControl} or {@link + * io.javaoperatorsdk.operator.api.reconciler.DeleteControl} + */ + interface ControllerExecution { + + /** + * Retrieves the name of type of reconciliation being performed: either {@code reconcile} or + * {@code cleanup}. + * + * @return the name of type of reconciliation being performed + */ + String name(); + + /** + * Retrieves the name of the controller executing the reconciliation. + * + * @return the associated controller name + */ + String controllerName(); + + /** + * Retrieves the name of the successful result when the reconciliation ended positively. + * Possible values comes from the different outcomes provided by {@link + * io.javaoperatorsdk.operator.api.reconciler.UpdateControl} or {@link + * io.javaoperatorsdk.operator.api.reconciler.DeleteControl}. + * + * @param result the reconciliation result + * @return a name associated with the specified outcome + */ + String successTypeName(T result); + + /** + * Retrieves the {@link ResourceID} of the resource associated with the controller execution + * being considered + * + * @return the {@link ResourceID} of the resource being reconciled + */ + ResourceID resourceID(); + + /** + * Retrieves metadata associated with the current reconciliation, typically additional + * information (such as kind) about the resource being reconciled + * + * @return metadata associated with the current reconciliation + */ + Map metadata(); + + /** + * Performs the controller execution. + * + * @return the result of the controller execution + * @throws Exception if an error occurred during the controller's execution + */ + T execute() throws Exception; + } + + /** + * Times the execution of the controller operation encapsulated by the provided {@link + * ControllerExecution}. + * + * @param execution the controller operation to be timed + * @return the result of the controller's execution if successful + * @param the type of the outcome/result of the controller's execution + * @throws Exception if an error occurred during the controller's execution, usually this should + * just be a pass-through of whatever the controller returned + */ + default T timeControllerExecution(ControllerExecution execution) throws Exception { + return execution.execute(); + } + + /** + * Monitors the size of the specified map. This currently isn't used directly by the SDK but could + * be used by operators to monitor some of their structures, such as cache size. + * + * @param map the Map which size is to be monitored + * @param name the name of the provided Map to be used in metrics data + * @return the Map that was passed in so the registration can be done as part of an assignment + * statement. + * @param the type of the Map being monitored + */ + @SuppressWarnings("unused") + default > T monitorSizeOf(T map, String name) { + return map; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java new file mode 100644 index 0000000000..a5cdb85257 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java @@ -0,0 +1,28 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +public abstract class BaseControl> { + + private Long scheduleDelay = null; + + public T rescheduleAfter(long delay) { + rescheduleAfter(Duration.ofMillis(delay)); + return (T) this; + } + + public T rescheduleAfter(Duration delay) { + this.scheduleDelay = delay.toMillis(); + return (T) this; + } + + public T rescheduleAfter(long delay, TimeUnit timeUnit) { + return rescheduleAfter(timeUnit.toMillis(delay)); + } + + public Optional getScheduleDelay() { + return Optional.ofNullable(scheduleDelay); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Cleaner.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Cleaner.java new file mode 100644 index 0000000000..edc7713846 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Cleaner.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public interface Cleaner

{ + + /** + * This method turns on automatic finalizer usage. + * + *

The implementation should delete the associated component(s). This method is called when an + * object is marked for deletion. After it's executed the custom resource finalizer is + * automatically removed by the framework; unless the return value is {@link + * DeleteControl#noFinalizerRemoval()}, which indicates that the controller has determined that + * the resource should not be deleted yet. This is usually a corner case, when a cleanup is tried + * again eventually. + * + *

It's important for implementations of this method to be idempotent, since it can be called + * several times. + * + * @param resource the resource that is marked for deletion + * @param context the context with which the operation is executed + * @return {@link DeleteControl#defaultDelete()} - so the finalizer is automatically removed after + * the call. Use {@link DeleteControl#noFinalizerRemoval()} when you don't want to remove the + * finalizer immediately but rather wait asynchronously until all secondary resources are + * deleted, thus allowing you to keep the primary resource around until you are sure that it + * can be safely deleted. + * @see DeleteControl#noFinalizerRemoval() + */ + DeleteControl cleanup(P resource, Context

context) throws Exception; +} 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 new file mode 100644 index 0000000000..0b0438bc23 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java @@ -0,0 +1,31 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.util.Collections; +import java.util.Set; + +public final class Constants { + + public static final String WATCH_CURRENT_NAMESPACE = "JOSDK_WATCH_CURRENT"; + public static final String WATCH_ALL_NAMESPACES = "JOSDK_ALL_NAMESPACES"; + public static final String SAME_AS_CONTROLLER = "JOSDK_SAME_AS_CONTROLLER"; + + public static final Set WATCH_ALL_NAMESPACE_SET = + Collections.singleton(Constants.WATCH_ALL_NAMESPACES); + public static final Set WATCH_CURRENT_NAMESPACE_SET = + Collections.singleton(Constants.WATCH_CURRENT_NAMESPACE); + public static final Set SAME_AS_CONTROLLER_NAMESPACES_SET = + Collections.singleton(Constants.SAME_AS_CONTROLLER); + + public static final Set DEFAULT_NAMESPACES_SET = WATCH_ALL_NAMESPACE_SET; + + public static final String NO_VALUE_SET = ""; + public static final long NO_LONG_VALUE_SET = -1L; + + public static final long NO_MAX_RECONCILIATION_INTERVAL = -1L; + + 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; + + 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 new file mode 100644 index 0000000000..f47deb9734 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java @@ -0,0 +1,75 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.stream.Stream; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache; + +public interface Context

{ + + Optional getRetryInfo(); + + default Optional getSecondaryResource(Class expectedType) { + return getSecondaryResource(expectedType, null); + } + + Set getSecondaryResources(Class expectedType); + + default Stream getSecondaryResourcesAsStream(Class expectedType) { + return getSecondaryResources(expectedType).stream(); + } + + Optional getSecondaryResource(Class expectedType, String eventSourceName); + + ControllerConfiguration

getControllerConfiguration(); + + /** + * Retrieve the {@link ManagedWorkflowAndDependentResourceContext} used to interact with {@link + * io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource}s and associated {@link + * io.javaoperatorsdk.operator.processing.dependent.workflow.Workflow} + * + * @return the {@link ManagedWorkflowAndDependentResourceContext} + */ + ManagedWorkflowAndDependentResourceContext managedWorkflowAndDependentResourceContext(); + + EventSourceRetriever

eventSourceRetriever(); + + KubernetesClient getClient(); + + /** ExecutorService initialized by framework for workflows. Used for workflow standalone mode. */ + ExecutorService getWorkflowExecutorService(); + + /** + * Retrieves the primary resource. + * + * @return the primary resource associated with the current reconciliation + */ + P getPrimaryResource(); + + /** + * Retrieves the primary resource cache. + * + * @return the {@link IndexerResourceCache} associated with the associated {@link Reconciler} for + * this context + */ + @SuppressWarnings("unused") + IndexedResourceCache

getPrimaryCache(); + + /** + * Determines whether a new reconciliation will be triggered right after the current + * reconciliation is finished. This allows to optimize certain situations, helping avoid unneeded + * API calls. A reconciler might, for example, skip updating the status when it's known another + * reconciliation is already scheduled, which would in turn trigger another status update, thus + * rendering the current one moot. + * + * @return {@code true} is another reconciliation is already scheduled, {@code false} otherwise + */ + boolean isNextReconciliationImminent(); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ContextInitializer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ContextInitializer.java new file mode 100644 index 0000000000..c1213cce67 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ContextInitializer.java @@ -0,0 +1,7 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public interface ContextInitializer

{ + void initContext(P primary, Context

context); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java new file mode 100644 index 0000000000..d407ed0fc6 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java @@ -0,0 +1,80 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.processing.event.rate.LinearRateLimiter; +import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; +import io.javaoperatorsdk.operator.processing.retry.Retry; + +import static io.javaoperatorsdk.operator.api.config.ControllerConfiguration.CONTROLLER_NAME_AS_FIELD_MANAGER; + +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface ControllerConfiguration { + + String name() default Constants.NO_VALUE_SET; + + Informer informer() default @Informer; + + /** + * Optional finalizer name, if it is not provided, one will be automatically generated. Note that + * finalizers are only added when Reconciler implement {@link Cleaner} interface and/or at least + * one managed dependent resource implements the {@link + * io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter} interface. + * + * @return the finalizer name + */ + String finalizerName() default Constants.NO_VALUE_SET; + + /** + * If true, will dispatch new event to the controller if generation increased since the last + * processing, otherwise will process all events. See generation meta attribute here + * + * @return whether the controller takes generation into account to process events + */ + boolean generationAwareEventProcessing() default true; + + /** + * Optional configuration of the maximal interval the SDK will wait for a reconciliation request + * to happen before one will be automatically triggered. The intention behind this feature is to + * have a failsafe, not to artificially force repeated reconciliations. For that use {@link + * UpdateControl#rescheduleAfter(long)}. + * + * @return the maximal reconciliation interval configuration + */ + MaxReconciliationInterval maxReconciliationInterval() default + @MaxReconciliationInterval(interval = MaxReconciliationInterval.DEFAULT_INTERVAL); + + /** + * Optional {@link Retry} implementation for the associated controller to use. + * + * @return the class providing the {@link Retry} implementation to use, needs to provide an + * accessible no-arg constructor. + */ + Class retry() default GenericRetry.class; + + /** + * Optional {@link RateLimiter} implementation for the associated controller to use. + * + * @return the class providing the {@link RateLimiter} implementation to use, needs to provide an + * accessible no-arg constructor. + */ + Class rateLimiter() default LinearRateLimiter.class; + + /** + * Retrieves the name used to assign as field manager for Server-Side Apply + * (SSA) operations. If unset, the sanitized controller name will be used. + * + * @return the name used as field manager for SSA operations + */ + String fieldManager() default CONTROLLER_NAME_AS_FIELD_MANAGER; +} 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 new file mode 100644 index 0000000000..2acf8d13ca --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java @@ -0,0 +1,126 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutorService; +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.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.DefaultManagedWorkflowAndDependentResourceContext; +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public class DefaultContext

implements Context

{ + + private RetryInfo retryInfo; + private final Controller

controller; + private final P primaryResource; + private final ControllerConfiguration

controllerConfiguration; + private final DefaultManagedWorkflowAndDependentResourceContext

+ defaultManagedDependentResourceContext; + + public DefaultContext(RetryInfo retryInfo, Controller

controller, P primaryResource) { + this.retryInfo = retryInfo; + this.controller = controller; + this.primaryResource = primaryResource; + this.controllerConfiguration = controller.getConfiguration(); + this.defaultManagedDependentResourceContext = + new DefaultManagedWorkflowAndDependentResourceContext<>(controller, primaryResource, this); + } + + @Override + public Optional getRetryInfo() { + return Optional.ofNullable(retryInfo); + } + + @Override + public Set getSecondaryResources(Class expectedType) { + 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); + } + + @Override + public Optional getSecondaryResource(Class expectedType, String eventSourceName) { + try { + return controller + .getEventSourceManager() + .getEventSourceFor(expectedType, eventSourceName) + .getSecondaryResource(primaryResource); + } catch (NoEventSourceForClassException e) { + /* + * If a workflow has an activation condition there can be event sources which are only + * registered if the activation condition holds, but to provide a consistent API we return an + * Optional instead of throwing an exception. + * + * Note that not only the resource which has an activation condition might not be registered + * but dependents which depend on it. + */ + if (eventSourceName == null && controller.workflowContainsDependentForType(expectedType)) { + return Optional.empty(); + } else { + throw e; + } + } + } + + @Override + public ControllerConfiguration

getControllerConfiguration() { + return controllerConfiguration; + } + + @Override + public ManagedWorkflowAndDependentResourceContext managedWorkflowAndDependentResourceContext() { + return defaultManagedDependentResourceContext; + } + + @Override + public EventSourceRetriever

eventSourceRetriever() { + return controller.getEventSourceManager(); + } + + @Override + public KubernetesClient getClient() { + return controller.getClient(); + } + + @Override + public ExecutorService getWorkflowExecutorService() { + // note that this should be always received from executor service manager, so we are able to do + // restarts. + return controller.getExecutorServiceManager().workflowExecutorService(); + } + + @Override + public P getPrimaryResource() { + return primaryResource; + } + + @Override + public IndexedResourceCache

getPrimaryCache() { + return controller.getEventSourceManager().getControllerEventSource(); + } + + @Override + public boolean isNextReconciliationImminent() { + return controller + .getEventProcessor() + .isNextReconciliationImminent(ResourceID.fromResource(primaryResource)); + } + + public DefaultContext

setRetryInfo(RetryInfo retryInfo) { + this.retryInfo = retryInfo; + return this; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DeleteControl.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DeleteControl.java new file mode 100644 index 0000000000..7160e70830 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DeleteControl.java @@ -0,0 +1,45 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +public class DeleteControl extends BaseControl { + + private final boolean removeFinalizer; + + private DeleteControl(boolean removeFinalizer) { + this.removeFinalizer = removeFinalizer; + } + + /** + * @return delete control that will remove finalizer. + */ + public static DeleteControl defaultDelete() { + return new DeleteControl(true); + } + + /** + * In some corner cases it might take some time for secondary resources to be cleaned up. In such + * situation, the reconciler shouldn't remove the finalizer until it is safe for the primary + * resource to be deleted (because, e.g., secondary resources being removed might need its + * information to proceed correctly). Using this method will instruct the reconciler to leave the + * finalizer in place, presumably to wait for a new reconciliation triggered by event sources on + * the secondary resources (e.g. when they are effectively deleted/cleaned-up) to check whether it + * is now safe to delete the primary resource and therefore, remove its finalizer by returning + * {@link #defaultDelete()} then. + * + * @return delete control that will not remove finalizer. + */ + public static DeleteControl noFinalizerRemoval() { + return new DeleteControl(false); + } + + public boolean isRemoveFinalizer() { + return removeFinalizer; + } + + @Override + public DeleteControl rescheduleAfter(long delay) { + if (removeFinalizer) { + throw new IllegalStateException("Cannot reschedule cleanup if removing finalizer"); + } + return super.rescheduleAfter(delay); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ErrorStatusUpdateControl.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ErrorStatusUpdateControl.java new file mode 100644 index 0000000000..e9073d613c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ErrorStatusUpdateControl.java @@ -0,0 +1,76 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.time.Duration; +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public class ErrorStatusUpdateControl

+ extends BaseControl> { + + private final P resource; + private boolean noRetry = false; + private final boolean defaultErrorProcessing; + + public static ErrorStatusUpdateControl patchStatus(T resource) { + return new ErrorStatusUpdateControl<>(resource); + } + + public static ErrorStatusUpdateControl noStatusUpdate() { + return new ErrorStatusUpdateControl<>(null); + } + + /** + * No special processing of the error, the error will be thrown and default error handling will + * apply + */ + public static ErrorStatusUpdateControl defaultErrorProcessing() { + return new ErrorStatusUpdateControl<>(null, true); + } + + private ErrorStatusUpdateControl(P resource) { + this(resource, false); + } + + private ErrorStatusUpdateControl(P resource, boolean defaultErrorProcessing) { + this.resource = resource; + this.defaultErrorProcessing = defaultErrorProcessing; + } + + /** + * Instructs the controller to not retry the error. This is useful for non-recoverable errors. + * + * @return ErrorStatusUpdateControl + */ + public ErrorStatusUpdateControl

withNoRetry() { + if (defaultErrorProcessing) { + throw new IllegalStateException("Cannot set no-retry for default error processing"); + } + this.noRetry = true; + return this; + } + + public Optional

getResource() { + return Optional.ofNullable(resource); + } + + public boolean isNoRetry() { + return noRetry; + } + + public boolean isDefaultErrorProcessing() { + return defaultErrorProcessing; + } + + /** + * If re-scheduled using this method, it is not considered as retry, it effectively cancels retry. + * + * @param delay for next execution + * @return ErrorStatusUpdateControl + */ + @Override + public ErrorStatusUpdateControl

rescheduleAfter(Duration delay) { + withNoRetry(); + return super.rescheduleAfter(delay); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/EventSourceContext.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/EventSourceContext.java new file mode 100644 index 0000000000..5f198a3d01 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/EventSourceContext.java @@ -0,0 +1,65 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache; + +/** + * Contextual information made available to event sources. + * + * @param

the type associated with the primary resource that is handled by your reconciler + */ +public class EventSourceContext

{ + + private final IndexerResourceCache

primaryCache; + private final ControllerConfiguration

controllerConfiguration; + private final KubernetesClient client; + private final Class

primaryResourceClass; + + public EventSourceContext( + IndexerResourceCache

primaryCache, + ControllerConfiguration

controllerConfiguration, + KubernetesClient client, + Class

primaryResourceClass) { + this.primaryCache = primaryCache; + this.controllerConfiguration = controllerConfiguration; + this.client = client; + this.primaryResourceClass = primaryResourceClass; + } + + /** + * Retrieves the cache that an {@link EventSource} can query to retrieve primary resources + * + * @return the primary resource cache + */ + public IndexerResourceCache

getPrimaryCache() { + return primaryCache; + } + + /** + * Retrieves the {@link ControllerConfiguration} associated with the operator. This allows, in + * particular, to lookup controller and global configuration information such as the configured* + * + * @return the {@link ControllerConfiguration} associated with the operator + */ + public ControllerConfiguration

getControllerConfiguration() { + return controllerConfiguration; + } + + /** + * Provides access to the {@link KubernetesClient} used by the current {@link + * io.javaoperatorsdk.operator.Operator} instance. + * + * @return the {@link KubernetesClient} used by the current {@link + * io.javaoperatorsdk.operator.Operator} instance. + */ + public KubernetesClient getClient() { + return client; + } + + public Class

getPrimaryResourceClass() { + return primaryResourceClass; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/EventSourceUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/EventSourceUtils.java new file mode 100644 index 0000000000..cf6cf21486 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/EventSourceUtils.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.util.*; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Workflow; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; + +public class EventSourceUtils { + + @SuppressWarnings("unchecked") + public static

List> dependentEventSources( + EventSourceContext

eventSourceContext, DependentResource... dependentResources) { + return Arrays.stream(dependentResources) + .flatMap(dr -> dr.eventSource(eventSourceContext).stream()) + .toList(); + } + + @SuppressWarnings("unchecked") + public static

List> eventSourcesFromWorkflow( + EventSourceContext

context, Workflow

workflow) { + return workflow.getDependentResourcesWithoutActivationCondition().stream() + .flatMap(dr -> dr.eventSource(context).stream()) + .toList(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Ignore.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Ignore.java new file mode 100644 index 0000000000..2383e9c399 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Ignore.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * An annotation for downstream tooling to ignore the annotated {@link Reconciler}. This allows to + * mark some implementations as not provided by user and should therefore be ignored by processes + * external to the SDK itself. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface Ignore {} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/IndexedResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/IndexedResourceCache.java new file mode 100644 index 0000000000..29ac9c073a --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/IndexedResourceCache.java @@ -0,0 +1,9 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.util.List; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public interface IndexedResourceCache extends ResourceCache { + List byIndex(String indexName, String indexKey); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/MaxReconciliationInterval.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/MaxReconciliationInterval.java new file mode 100644 index 0000000000..9a1635b16b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/MaxReconciliationInterval.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface MaxReconciliationInterval { + + long DEFAULT_INTERVAL = 10; + + /** + * A max delay between two reconciliations. Having this value larger than zero, will ensure that a + * reconciliation is scheduled with a target interval after the last reconciliation. Note that + * this not applies for retries, in case of an exception reconciliation is not scheduled. This is + * not a fixed rate, in other words a new reconciliation is scheduled after each reconciliation. + * + *

If an interval is specified by {@link UpdateControl} or {@link DeleteControl}, those take + * precedence. + * + *

This is a fail-safe feature, in the sense that if informers are in place and the reconciler + * implementation is correct, this feature can be turned off. + * + *

Use {@link Constants#NO_MAX_RECONCILIATION_INTERVAL} to turn off this feature. + * + * @return max delay between reconciliations + */ + long interval(); + + /** + * @return time unit for max delay between reconciliations + */ + TimeUnit timeUnit() default TimeUnit.HOURS; +} 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 new file mode 100644 index 0000000000..c61cc837c1 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtils.java @@ -0,0 +1,232 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +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; + +/** + * Utility methods to patch the primary resource state and store it to the related cache, to make + * sure that the latest version of the resource is present for the next reconciliation. The main use + * case for such updates is to store state is resource status. + * + *

The way the framework handles this is with retryable updates with optimistic locking, and + * 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. + */ +public class PrimaryUpdateAndCacheUtils { + + public static final int DEFAULT_MAX_RETRY = 10; + public static final int DEFAULT_RESOURCE_CACHE_TIMEOUT_MILLIS = 10000; + public static final int DEFAULT_RESOURCE_CACHE_POLL_PERIOD_MILLIS = 50; + + private PrimaryUpdateAndCacheUtils() {} + + private static final Logger log = LoggerFactory.getLogger(PrimaryUpdateAndCacheUtils.class); + + /** + * Updates the status with optimistic locking and caches the result for next reconciliation. For + * details see {@link #updateAndCacheResource}. + */ + public static

P updateStatusAndCacheResource( + P primary, Context

context, UnaryOperator

modificationFunction) { + return updateAndCacheResource( + primary, + context, + modificationFunction, + r -> context.getClient().resource(r).updateStatus()); + } + + /** + * Patches the status using JSON Merge Patch with optimistic locking and caches the result for + * next reconciliation. For details see {@link #updateAndCacheResource}. + */ + public static

P mergePatchStatusAndCacheResource( + P primary, Context

context, UnaryOperator

modificationFunction) { + return updateAndCacheResource( + primary, context, modificationFunction, r -> context.getClient().resource(r).patchStatus()); + } + + /** + * Patches the status using JSON Patch with optimistic locking and caches the result for next + * reconciliation. For details see {@link #updateAndCacheResource}. + */ + public static

P patchStatusAndCacheResource( + P primary, Context

context, UnaryOperator

modificationFunction) { + return updateAndCacheResource( + primary, + context, + UnaryOperator.identity(), + r -> context.getClient().resource(r).editStatus(modificationFunction)); + } + + /** + * Patches the status using Server Side Apply with optimistic locking and caches the result for + * next reconciliation. For details see {@link #updateAndCacheResource}. + */ + public static

P ssaPatchStatusAndCacheResource( + P primary, P freshResourceWithStatus, Context

context) { + return updateAndCacheResource( + primary, + context, + r -> freshResourceWithStatus, + r -> + context + .getClient() + .resource(r) + .subresource("status") + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager(context.getControllerConfiguration().fieldManager()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build())); + } + + /** + * Same as {@link #updateAndCacheResource(HasMetadata, Context, UnaryOperator, UnaryOperator, int, + * long,long)} using the default maximum retry number as defined by {@link #DEFAULT_MAX_RETRY} and + * default cache maximum polling time and period as defined, respectively by {@link + * #DEFAULT_RESOURCE_CACHE_TIMEOUT_MILLIS} and {@link #DEFAULT_RESOURCE_CACHE_POLL_PERIOD_MILLIS}. + * + * @param resourceToUpdate original resource to update + * @param context of reconciliation + * @param modificationFunction modifications to make on primary + * @param updateMethod the update method implementation + * @param

primary type + * @return the updated resource + */ + public static

P updateAndCacheResource( + P resourceToUpdate, + Context

context, + UnaryOperator

modificationFunction, + UnaryOperator

updateMethod) { + return updateAndCacheResource( + resourceToUpdate, + context, + modificationFunction, + updateMethod, + DEFAULT_MAX_RETRY, + DEFAULT_RESOURCE_CACHE_TIMEOUT_MILLIS, + DEFAULT_RESOURCE_CACHE_POLL_PERIOD_MILLIS); + } + + /** + * Modifies the primary using the specified modification function, then uses the modified resource + * for the request to update with provided update method. As the {@code resourceVersion} field of + * the modified resource is set to the value found in the specified resource to update, the update + * operation will therefore use optimistic locking on the server. If the request fails on + * optimistic update, we read the resource again from the K8S API server and retry the whole + * process. In short, we make sure we always update the resource with optimistic locking, then we + * cache the resource in an internal cache. Without further going into details, the optimistic + * locking is needed so we can reliably handle the caching. + * + * @param resourceToUpdate original resource to update + * @param context of reconciliation + * @param modificationFunction modifications to make on primary + * @param updateMethod the update method implementation + * @param maxRetry maximum number of retries before giving up + * @param cachePollTimeoutMillis maximum amount of milliseconds to wait for the updated resource + * to appear in cache + * @param cachePollPeriodMillis cache polling period, in milliseconds + * @param

primary type + * @return the updated resource + */ + public static

P updateAndCacheResource( + P resourceToUpdate, + Context

context, + UnaryOperator

modificationFunction, + UnaryOperator

updateMethod, + int maxRetry, + long cachePollTimeoutMillis, + long cachePollPeriodMillis) { + + if (log.isDebugEnabled()) { + log.debug("Conflict retrying update for: {}", ResourceID.fromResource(resourceToUpdate)); + } + P modified = null; + int retryIndex = 0; + while (true) { + try { + modified = modificationFunction.apply(resourceToUpdate); + modified + .getMetadata() + .setResourceVersion(resourceToUpdate.getMetadata().getResourceVersion()); + var updated = updateMethod.apply(modified); + context + .eventSourceRetriever() + .getControllerEventSource() + .handleRecentResourceUpdate( + ResourceID.fromResource(resourceToUpdate), updated, resourceToUpdate); + return updated; + } catch (KubernetesClientException e) { + log.trace("Exception during patch for resource: {}", resourceToUpdate); + 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 > maxRetry) { + log.warn("Retry exhausted, last desired resource: {}", modified); + throw new OperatorException( + "Exceeded maximum (" + + maxRetry + + ") retry attempts to patch resource: " + + ResourceID.fromResource(resourceToUpdate), + e); + } + log.debug( + "Retrying patch for resource name: {}, namespace: {}; HTTP code: {}", + resourceToUpdate.getMetadata().getName(), + resourceToUpdate.getMetadata().getNamespace(), + e.getCode()); + resourceToUpdate = + pollLocalCache( + context, resourceToUpdate, cachePollTimeoutMillis, cachePollPeriodMillis); + } + } + } + + private static

P pollLocalCache( + Context

context, P staleResource, long timeoutMillis, long pollDelayMillis) { + try { + var resourceId = ResourceID.fromResource(staleResource); + var startTime = LocalTime.now(); + final var timeoutTime = startTime.plus(timeoutMillis, ChronoUnit.MILLIS); + while (timeoutTime.isAfter(LocalTime.now())) { + log.debug("Polling cache for resource: {}", resourceId); + var cachedResource = context.getPrimaryCache().get(resourceId).orElseThrow(); + if (!cachedResource + .getMetadata() + .getResourceVersion() + .equals(staleResource.getMetadata().getResourceVersion())) { + return context + .getControllerConfiguration() + .getConfigurationService() + .getResourceCloner() + .clone(cachedResource); + } + Thread.sleep(pollDelayMillis); + } + throw new OperatorException("Timeout of resource polling from cache for resource"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new OperatorException(e); + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Reconciler.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Reconciler.java new file mode 100644 index 0000000000..4075903787 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Reconciler.java @@ -0,0 +1,56 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.util.*; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; + +public interface Reconciler

{ + + /** + * The implementation of this operation is required to be idempotent. Always use the UpdateControl + * object to make updates on custom resource if possible. + * + * @throws Exception from the custom implementation + * @param resource the resource that has been created or updated + * @param context the context with which the operation is executed + * @return UpdateControl to manage updates on the custom resource (usually the status) after + * reconciliation. + */ + UpdateControl

reconcile(P resource, Context

context) throws Exception; + + /** + * Prepares a map of {@link EventSource} implementations keyed by the name with which they need to + * be registered by the SDK. + * + * @param context a {@link EventSourceContext} providing access to information useful to event + * sources + * @return a list of event sources + */ + default List> prepareEventSources(EventSourceContext

context) { + return Collections.emptyList(); + } + + /** + * Reconciler can override this method in order to update the status sub-resource in the case an + * exception in thrown. In that case {@link #updateErrorStatus(HasMetadata, Context, Exception)} + * is called automatically. + * + *

The result of the method call is used to make a status update on the custom resource. This + * is always a sub-resource update request, so no update on custom resource itself (like spec of + * metadata) happens. Note that this update request will also produce an event, and will result in + * a reconciliation if the controller is not generation aware. + * + *

Note that the scope of this feature is only the reconcile method of the reconciler, since + * there should not be updates on custom resource after it is marked for deletion. + * + * @param resource to update the status on + * @param context the current context + * @param e exception thrown from the reconciler + * @return the updated resource + */ + default ErrorStatusUpdateControl

updateErrorStatus( + P resource, Context

context, Exception e) { + return ErrorStatusUpdateControl.defaultErrorProcessing(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceCache.java new file mode 100644 index 0000000000..130bd23e8d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceCache.java @@ -0,0 +1,17 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.util.function.Predicate; +import java.util.stream.Stream; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.source.Cache; + +@SuppressWarnings("unchecked") +public interface ResourceCache extends Cache { + + default Stream list(String namespace) { + return list(namespace, TRUE); + } + + Stream list(String namespace, Predicate predicate); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/RetryInfo.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/RetryInfo.java new file mode 100644 index 0000000000..26996d6c06 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/RetryInfo.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +public interface RetryInfo { + /** + * @return current retry attempt count. 0 if the current execution is not a retry. + */ + int getAttemptCount(); + + /** + * @return true, if the current attempt is the last one in regard to the retry limit + * configuration. + */ + boolean isLastAttempt(); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/UpdateControl.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/UpdateControl.java new file mode 100644 index 0000000000..1bd98c12d6 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/UpdateControl.java @@ -0,0 +1,76 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.CustomResource; + +public class UpdateControl

extends BaseControl> { + + private final P resource; + private final boolean patchResource; + private final boolean patchStatus; + + private UpdateControl(P resource, boolean patchResource, boolean patchStatus) { + if ((patchResource || patchStatus) && resource == null) { + throw new IllegalArgumentException("CustomResource cannot be null in case of update"); + } + this.resource = resource; + this.patchResource = patchResource; + this.patchStatus = patchStatus; + } + + /** + * Preferred way to update the status. Uses JSON Patch to patch the resource. + * + *

Note that this does not work, if the {@link CustomResource#initStatus()} is implemented, + * since it breaks the diffing process. Don't implement it if using this method. There is also an + * issue with setting value to {@code null} with older Kubernetes versions (1.19 and below). See: + * https://github.com/fabric8io/kubernetes-client/issues/4158 + * + * @param resource type + * @param customResource the custom resource with target status + * @return UpdateControl instance + */ + public static UpdateControl patchStatus(T customResource) { + return new UpdateControl<>(customResource, false, true); + } + + public static UpdateControl patchResource(T customResource) { + return new UpdateControl<>(customResource, true, false); + } + + /** + * @param customResource to update + * @return UpdateControl instance + * @param resource type + */ + public static UpdateControl patchResourceAndStatus(T customResource) { + return new UpdateControl<>(customResource, true, true); + } + + public static UpdateControl noUpdate() { + return new UpdateControl<>(null, false, false); + } + + public Optional

getResource() { + return Optional.ofNullable(resource); + } + + public boolean isPatchResource() { + return patchResource; + } + + public boolean isPatchStatus() { + return patchStatus; + } + + public boolean isNoUpdate() { + return !patchResource && !patchStatus; + } + + public boolean isPatchResourceAndStatus() { + return patchResource && patchStatus; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Workflow.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Workflow.java new file mode 100644 index 0000000000..16a9f7ba4b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Workflow.java @@ -0,0 +1,41 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowCleanupResult; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowReconcileResult; + +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface Workflow { + + Dependent[] dependents(); + + /** + * If {@code true}, the managed workflow should be explicitly invoked within the reconciler + * implementation. If {@code false}, the workflow is invoked just before the {@link + * Reconciler#reconcile(HasMetadata, Context)} method. + */ + boolean explicitInvocation() default false; + + /** + * If {@code true} and exceptions are thrown during the workflow's execution, the reconciler won't + * throw an {@link io.javaoperatorsdk.operator.AggregatedOperatorException} at the end of the + * execution as would normally be the case. Instead, it will proceed to its {@link + * Reconciler#reconcile(HasMetadata, Context)} method as if no error occurred. It is then up to + * the developer to decide how to proceed by retrieving the errored dependents (and their + * associated exception) via {@link WorkflowReconcileResult#getErroredDependents()} or {@link + * WorkflowCleanupResult#getErroredDependents()}, the workflow result itself being accessed from + * {@link Context#managedWorkflowAndDependentResourceContext()}. If {@code false}, an exception + * will be automatically thrown at the end of the workflow execution, presenting an aggregated + * view of what happened. + */ + boolean handleExceptionsInReconciler() default false; +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/Deleter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/Deleter.java new file mode 100644 index 0000000000..f6bd2682ca --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/Deleter.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +/** + * DependentResource can implement this interface to denote it requires explicit logic to clean up + * resources. + * + * @param

primary resource type + */ +@FunctionalInterface +public interface Deleter

{ + void delete(P primary, Context

context); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/Dependent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/Dependent.java new file mode 100644 index 0000000000..dd7fd8404e --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/Dependent.java @@ -0,0 +1,86 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent; + +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_VALUE_SET; + +/** + * The annotation used to create managed {@link DependentResource} associated with a given {@link + * io.javaoperatorsdk.operator.api.reconciler.Reconciler} + */ +public @interface Dependent { + + @SuppressWarnings("rawtypes") + Class type(); + + /** + * The name of this dependent. This is needed to be able to refer to it when creating dependencies + * between dependent resources. + * + * @return the name if it has been set, {@link + * io.javaoperatorsdk.operator.api.reconciler.Constants#NO_VALUE_SET} otherwise + */ + String name() default NO_VALUE_SET; + + /** + * The condition (if it exists) that needs to become true before the workflow can further proceed. + * + * @return a {@link Condition} implementation, defaulting to the interface itself if no value is + * set + */ + Class readyPostcondition() default Condition.class; + + /** + * The condition (if it exists) that needs to become true before the associated {@link + * DependentResource} is reconciled. Note that if this condition is set and the condition doesn't + * hold true, the associated secondary will be deleted. + * + * @return a {@link Condition} implementation, defaulting to the interface itself if no value is + * set + */ + Class reconcilePrecondition() default Condition.class; + + /** + * The condition (if it exists) that needs to become true before further reconciliation of + * dependents can proceed after the secondary resource associated with this dependent is supposed + * to have been deleted. + * + * @return a {@link Condition} implementation, defaulting to the interface itself if no value is + * set + */ + Class deletePostcondition() default Condition.class; + + /** + * A condition that needs to become true for the dependent to even be considered as part of the + * workflow. This is useful for dependents that represent optional resources on the cluster and + * might not be present. In this case, a reconcile pre-condition is not enough because in that + * situation, the associated informer will still be registered. With this activation condition, + * the associated event source will only be registered if the condition is met. Otherwise, this + * behaves like a reconcile pre-condition in the sense that dependents, that depend on this one, + * will only get created if the condition is met and will get deleted if the condition becomes + * false. + * + *

As other conditions, this gets evaluated at the beginning of every reconciliation, which + * means that it allows to react to optional resources becoming available on the cluster as the + * operator runs. More specifically, this means that the associated event source can get + * dynamically registered or de-registered during reconciliation. + */ + Class activationCondition() default Condition.class; + + /** + * The list of named dependents that need to be reconciled before this one can be. + * + * @return the list (possibly empty) of named dependents that need to be reconciled before this + * one can be + */ + String[] dependsOn() default {}; + + /** + * Setting here a name of the event source means that dependent resource will use an event source + * registered with that name. So won't create one. This is helpful if more dependent resources + * created for the same type, and want to share a common event source. + * + * @return event source name (if any) provided by the dependent resource should be used. + */ + String useEventSourceWithName() default NO_VALUE_SET; +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/DependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/DependentResource.java new file mode 100644 index 0000000000..49c9df3c7d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/DependentResource.java @@ -0,0 +1,100 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent; + +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; + +/** + * An interface to implement and provide dependent resource support. + * + * @param the dependent resource type + * @param

the associated primary resource type + */ +public interface DependentResource { + + /** + * Computes a default name for the specified DependentResource class + * + * @param dependentResourceClass the DependentResource class for which we want to compute a + * default name + * @return the default name for the specified DependentResource class + */ + @SuppressWarnings("rawtypes") + static String defaultNameFor(Class dependentResourceClass) { + return dependentResourceClass.getName(); + } + + /** + * Reconciles the dependent resource given the desired primary state + * + * @param primary the primary resource for which we want to reconcile the dependent state + * @param context {@link Context} providing useful contextual information + * @return a {@link ReconcileResult} providing information about the reconciliation result + */ + ReconcileResult reconcile(P primary, Context

context); + + /** + * Retrieves the resource type associated with this DependentResource + * + * @return the resource type associated with this DependentResource + */ + Class resourceType(); + + /** + * Dependent resources are designed to provide event sources by default. There are, however, cases + * where they might not: + * + *

    + *
  • If an event source is shared between multiple dependent resources. In this case only one + * or none of the dependent resources sharing the event source should provide one, if any. + *
  • Some special implementation of an event source that just executes some action might not + * provide one. + *
+ * + * @param eventSourceContext context of event source initialization + * @return an optional event source initialized from the specified context + * @since 5.0.0 + */ + default Optional> eventSource( + EventSourceContext

eventSourceContext) { + return Optional.empty(); + } + + /** + * Retrieves the secondary resource (if it exists) associated with the specified primary resource + * for this DependentResource. + * + * @param primary the primary resource for which we want to retrieve the secondary resource + * associated with this DependentResource + * @param context the current {@link Context} in which the operation is called + * @return the secondary resource or {@link Optional#empty()} if it doesn't exist + * @throws IllegalStateException if more than one secondary is found to match the primary resource + */ + default Optional getSecondaryResource(P primary, Context

context) { + return Optional.empty(); + } + + /** + * Determines whether resources associated with this dependent need explicit handling when + * deleted, usually meaning that the dependent implements {@link Deleter} + * + * @return {@code true} if explicit handling of resource deletion is needed, {@code false} + * otherwise + */ + default boolean isDeletable() { + return this instanceof Deleter; + } + + /** + * Retrieves the name identifying this DependentResource implementation, useful to refer to this + * in {@link io.javaoperatorsdk.operator.processing.dependent.workflow.Workflow} instances + * + * @return the name identifying this DependentResource implementation + */ + default String name() { + return defaultNameFor(getClass()); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/DependentResourceFactory.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/DependentResourceFactory.java new file mode 100644 index 0000000000..d6a2971515 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/DependentResourceFactory.java @@ -0,0 +1,49 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent; + +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.Utils; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ConfiguredDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.DependentResourceNode; + +@SuppressWarnings({"rawtypes", "unchecked"}) +public interface DependentResourceFactory< + C extends ControllerConfiguration, D extends DependentResourceSpec> { + + DependentResourceFactory DEFAULT = new DependentResourceFactory() {}; + + default DependentResource createFrom(D spec, C controllerConfiguration) { + final var dependentResourceClass = spec.getDependentResourceClass(); + return Utils.instantiateAndConfigureIfNeeded( + dependentResourceClass, + DependentResource.class, + Utils.contextFor(controllerConfiguration, dependentResourceClass, Dependent.class), + (instance) -> configure(instance, spec, controllerConfiguration)); + } + + default void configure(DependentResource instance, D spec, C controllerConfiguration) { + if (instance instanceof ConfiguredDependentResource configurable) { + final var config = controllerConfiguration.getConfigurationFor(spec); + if (config != null) { + configurable.configureWith(config); + } + } + } + + default Class associatedResourceType(D spec) { + final var dependentResourceClass = spec.getDependentResourceClass(); + final var dr = + Utils.instantiateAndConfigureIfNeeded( + dependentResourceClass, DependentResource.class, null, null); + return dr != null ? dr.resourceType() : null; + } + + default DependentResourceNode createNodeFrom(D spec, DependentResource dependentResource) { + return new DependentResourceNode( + spec.getReconcileCondition(), + spec.getDeletePostCondition(), + spec.getReadyCondition(), + spec.getActivationCondition(), + dependentResource); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/EventSourceNotFoundException.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/EventSourceNotFoundException.java new file mode 100644 index 0000000000..d4fcf139c9 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/EventSourceNotFoundException.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent; + +import io.javaoperatorsdk.operator.OperatorException; + +public class EventSourceNotFoundException extends OperatorException { + + private final String eventSourceName; + + public EventSourceNotFoundException(String eventSourceName) { + this.eventSourceName = eventSourceName; + } + + public String getEventSourceName() { + return eventSourceName; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/EventSourceReferencer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/EventSourceReferencer.java new file mode 100644 index 0000000000..d1b288df24 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/EventSourceReferencer.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; + +public interface EventSourceReferencer

{ + + default void useEventSourceWithName(String name) {} + + /** + * Throws {@link EventSourceNotFoundException} an exception if the target event source to use is + * not found. + * + * @param eventSourceRetriever for event sources + */ + void resolveEventSource(EventSourceRetriever

eventSourceRetriever) + throws EventSourceNotFoundException; +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/GarbageCollected.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/GarbageCollected.java new file mode 100644 index 0000000000..002f95ec1b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/GarbageCollected.java @@ -0,0 +1,24 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +/** + * Can be implemented by a dependent resource extending {@link + * io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource} to + * express that the resource deletion is handled by the controller during {@link + * DependentResource#reconcile(HasMetadata, Context)}. This takes effect during a reconciliation + * workflow, but not during a cleanup workflow, when a {@code reconcilePrecondition} is not met for + * the resource. In this case, {@link #delete(HasMetadata, Context)} is called. During a cleanup + * workflow, however, {@link #delete(HasMetadata, Context)} is not called, letting the Kubernetes + * garbage collector do its work instead (using owner references). + * + *

If a dependent resource implement this interface, an owner reference pointing to the + * associated primary resource will be automatically added to this managed resource. + * + *

See this + * issue for more details. + * + * @param

primary resource type + */ +public interface GarbageCollected

extends Deleter

{} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/NameSetter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/NameSetter.java new file mode 100644 index 0000000000..25c87045f1 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/NameSetter.java @@ -0,0 +1,6 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent; + +public interface NameSetter { + + void setName(String name); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/RecentOperationCacheFiller.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/RecentOperationCacheFiller.java new file mode 100644 index 0000000000..cb84783a4f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/RecentOperationCacheFiller.java @@ -0,0 +1,10 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public interface RecentOperationCacheFiller { + + void handleRecentResourceCreate(ResourceID resourceID, R resource); + + void handleRecentResourceUpdate(ResourceID resourceID, R resource, R previousVersionOfResource); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/ReconcileResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/ReconcileResult.java new file mode 100644 index 0000000000..af416a748a --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/ReconcileResult.java @@ -0,0 +1,78 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent; + +import java.util.*; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public class ReconcileResult { + + private final Map resourceOperations; + + public static ReconcileResult resourceCreated(T resource) { + return new ReconcileResult<>(resource, Operation.CREATED); + } + + public static ReconcileResult resourceUpdated(T resource) { + return new ReconcileResult<>(resource, Operation.UPDATED); + } + + public static ReconcileResult noOperation(T resource) { + return new ReconcileResult<>(resource, Operation.NONE); + } + + public static ReconcileResult aggregatedResult(List> results) { + if (results == null) { + throw new IllegalArgumentException("Should provide results to aggregate"); + } + if (results.size() == 1) { + return results.get(0); + } + final Map operations = new HashMap<>(results.size()); + for (ReconcileResult res : results) { + res.getSingleResource().ifPresent(r -> operations.put(r, res.getSingleOperation())); + } + return new ReconcileResult<>(operations); + } + + @Override + public String toString() { + return resourceOperations.entrySet().stream() + .collect( + Collectors.toMap( + e -> e instanceof HasMetadata ? ResourceID.fromResource((HasMetadata) e) : e, + Map.Entry::getValue)) + .toString(); + } + + private ReconcileResult(R resource, Operation operation) { + resourceOperations = resource != null ? Map.of(resource, operation) : Collections.emptyMap(); + } + + private ReconcileResult(Map operations) { + resourceOperations = Collections.unmodifiableMap(operations); + } + + public Optional getSingleResource() { + return resourceOperations.entrySet().stream().findFirst().map(Map.Entry::getKey); + } + + public Operation getSingleOperation() { + return resourceOperations.entrySet().stream() + .findFirst() + .map(Map.Entry::getValue) + .orElseThrow(); + } + + @SuppressWarnings("unused") + public Map getResourceOperations() { + return resourceOperations; + } + + public enum Operation { + CREATED, + UPDATED, + NONE + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/ConfiguredDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/ConfiguredDependentResource.java new file mode 100644 index 0000000000..2c9946d866 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/ConfiguredDependentResource.java @@ -0,0 +1,9 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent.managed; + +import java.util.Optional; + +public interface ConfiguredDependentResource { + void configureWith(C config); + + Optional configuration(); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/DefaultManagedWorkflowAndDependentResourceContext.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/DefaultManagedWorkflowAndDependentResourceContext.java new file mode 100644 index 0000000000..8adfaad44c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/DefaultManagedWorkflowAndDependentResourceContext.java @@ -0,0 +1,110 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent.managed; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowCleanupResult; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowReconcileResult; + +@SuppressWarnings("rawtypes") +public class DefaultManagedWorkflowAndDependentResourceContext

+ implements ManagedWorkflowAndDependentResourceContext { + private static final Logger log = + LoggerFactory.getLogger(DefaultManagedWorkflowAndDependentResourceContext.class); + public static final Object RECONCILE_RESULT_KEY = new Object(); + public static final Object CLEANUP_RESULT_KEY = new Object(); + private final ConcurrentHashMap attributes = new ConcurrentHashMap(); + private final Controller

controller; + private final P primaryResource; + private final Context

context; + + public DefaultManagedWorkflowAndDependentResourceContext( + Controller

controller, P primaryResource, Context

context) { + this.controller = controller; + this.primaryResource = primaryResource; + this.context = context; + } + + @Override + public Optional get(Object key, Class expectedType) { + return Optional.ofNullable(attributes.get(key)) + .filter(expectedType::isInstance) + .map(expectedType::cast); + } + + @Override + @SuppressWarnings("unchecked") + public T put(Object key, T value) { + Object previous; + if (value == null) { + return (T) attributes.remove(key); + } else { + previous = attributes.put(key, value); + } + + if (previous != null && !previous.getClass().isAssignableFrom(value.getClass())) { + logWarning( + "Previous value (" + + previous + + ") for key (" + + key + + ") was not of type " + + value.getClass() + + ". This might indicate an issue in your code. If not, use put(" + + key + + ", null) first to remove the previous value."); + } + return (T) previous; + } + + // only for testing purposes + void logWarning(String message) { + log.warn(message); + } + + @Override + @SuppressWarnings("unused") + public T getMandatory(Object key, Class expectedType) { + return get(key, expectedType) + .orElseThrow( + () -> + new IllegalStateException( + "Mandatory attribute (key: " + + key + + ", type: " + + expectedType.getName() + + ") is missing or not of the expected type")); + } + + @Override + public Optional getWorkflowReconcileResult() { + return get(RECONCILE_RESULT_KEY, WorkflowReconcileResult.class); + } + + @Override + public Optional getWorkflowCleanupResult() { + return get(CLEANUP_RESULT_KEY, WorkflowCleanupResult.class); + } + + @Override + public WorkflowReconcileResult reconcileManagedWorkflow() { + if (!controller.isWorkflowExplicitInvocation()) { + throw new IllegalStateException("Workflow explicit invocation is not set."); + } + return controller.reconcileManagedWorkflow(primaryResource, context); + } + + @Override + public WorkflowCleanupResult cleanupManageWorkflow() { + if (!controller.isWorkflowExplicitInvocation()) { + throw new IllegalStateException("Workflow explicit invocation is not set."); + } + return controller.cleanupManagedWorkflow(primaryResource, context); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/ManagedDependentResourceException.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/ManagedDependentResourceException.java new file mode 100644 index 0000000000..5c09a47e08 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/ManagedDependentResourceException.java @@ -0,0 +1,17 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent.managed; + +import io.javaoperatorsdk.operator.OperatorException; + +public class ManagedDependentResourceException extends OperatorException { + private final String associatedDependentName; + + public ManagedDependentResourceException( + String associatedDependentName, String message, Throwable cause) { + super(message, cause); + this.associatedDependentName = associatedDependentName; + } + + public String getAssociatedDependentName() { + return associatedDependentName; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/ManagedWorkflowAndDependentResourceContext.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/ManagedWorkflowAndDependentResourceContext.java new file mode 100644 index 0000000000..0217787049 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/ManagedWorkflowAndDependentResourceContext.java @@ -0,0 +1,86 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent.managed; + +import java.util.Optional; + +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowCleanupResult; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowReconcileResult; + +/** + * Contextual information related to {@link DependentResource} either to retrieve the actual + * implementations to interact with them or to pass information between them and/or the reconciler + */ +public interface ManagedWorkflowAndDependentResourceContext { + + /** + * Retrieve a contextual object, if it exists and is of the specified expected type, associated + * with the specified key. Contextual objects can be used to pass data between the reconciler and + * dependent resources and are scoped to the current reconciliation. + * + * @param key the key identifying which contextual object to retrieve + * @param expectedType the class representing the expected type of the contextual object + * @param the type of the expected contextual object + * @return an Optional containing the contextual object or {@link Optional#empty()} if no such + * object exists or doesn't match the expected type + */ + Optional get(Object key, Class expectedType); + + /** + * Associates the specified contextual value to the specified key. If the value is {@code null}, + * the semantics of this operation is defined as removing the mapping associated with the + * specified key. + * + *

Note that, while implementations shouldn't throw a {@link ClassCastException} when the new + * value type differs from the type of the existing value, calling sites might encounter such + * exceptions if they bind the return value to a specific type. Users are either expected to + * disregard the return value (most common case) or "reset" the value type associated with the + * specified key by first calling {@code put(key, null)} if they want to ensure some level of type + * safety in their code (where attempting to store values of different types under the same key + * might be indicative of an issue). + * + * @param object type + * @param key the key identifying which contextual object to add or remove from the context + * @param value the value to add to the context or {@code null} to remove an existing entry + * associated with the specified key + * @return the previous value if one was associated with the specified key, {@code null} + * otherwise. + */ + T put(Object key, T value); + + /** + * Retrieves the value associated with the key or fail with an exception if none exists. + * + * @param key the key identifying which contextual object to retrieve + * @param expectedType the expected type of the value to retrieve + * @param the type of the expected contextual object + * @return the contextual object value associated with the specified key + * @see #get(Object, Class) + */ + @SuppressWarnings("unused") + T getMandatory(Object key, Class expectedType); + + Optional getWorkflowReconcileResult(); + + @SuppressWarnings("unused") + Optional getWorkflowCleanupResult(); + + /** + * Explicitly reconcile the declared workflow for the associated {@link + * io.javaoperatorsdk.operator.api.reconciler.Reconciler} + * + * @return the result of the workflow reconciliation + * @throws IllegalStateException if called when explicit invocation is not requested + */ + WorkflowReconcileResult reconcileManagedWorkflow(); + + /** + * Explicitly clean-up dependent resources in the declared workflow for the associated {@link + * io.javaoperatorsdk.operator.api.reconciler.Reconciler}. Note that calling this method is only + * needed if the associated reconciler implements the {@link + * io.javaoperatorsdk.operator.api.reconciler.Cleaner} interface. + * + * @return the result of the workflow reconciliation on cleanup + * @throws IllegalStateException if called when explicit invocation is not requested + */ + WorkflowCleanupResult cleanupManageWorkflow(); +} 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 new file mode 100644 index 0000000000..de96dd27a9 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/ControllerHealthInfo.java @@ -0,0 +1,53 @@ +package io.javaoperatorsdk.operator.health; + +import java.util.Map; +import java.util.stream.Collectors; + +import io.javaoperatorsdk.operator.processing.event.EventSourceManager; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; + +@SuppressWarnings("rawtypes") +public class ControllerHealthInfo { + + private final EventSourceManager eventSourceManager; + + public ControllerHealthInfo(EventSourceManager eventSourceManager) { + this.eventSourceManager = eventSourceManager; + } + + public Map eventSourceHealthIndicators() { + return eventSourceManager.allEventSources().stream() + .collect(Collectors.toMap(EventSource::name, e -> e)); + } + + public Map unhealthyEventSources() { + return eventSourceManager.allEventSources().stream() + .filter(e -> e.getStatus() == Status.UNHEALTHY) + .collect(Collectors.toMap(EventSource::name, e -> e)); + } + + public Map + informerEventSourceHealthIndicators() { + return eventSourceManager.allEventSources().stream() + .filter(e -> e instanceof InformerWrappingEventSourceHealthIndicator) + .collect( + Collectors.toMap( + EventSource::name, e -> (InformerWrappingEventSourceHealthIndicator) e)); + } + + /** + * @return Map with event sources that wraps an informer. Thus, either a {@link + * ControllerEventSource} or an {@link + * io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource}. + */ + 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)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/EventSourceHealthIndicator.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/EventSourceHealthIndicator.java new file mode 100644 index 0000000000..ac8ddd4b1f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/EventSourceHealthIndicator.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.health; + +public interface EventSourceHealthIndicator { + + /** + * Retrieves the health status of an {@link + * io.javaoperatorsdk.operator.processing.event.source.EventSource} + * + * @return the health status + * @see Status + */ + Status getStatus(); +} 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 new file mode 100644 index 0000000000..4cdb96d8f8 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerHealthIndicator.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.health; + +public interface InformerHealthIndicator extends EventSourceHealthIndicator { + + boolean hasSynced(); + + boolean isWatching(); + + boolean isRunning(); + + @Override + Status getStatus(); + + String getTargetNamespace(); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerWrappingEventSourceHealthIndicator.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerWrappingEventSourceHealthIndicator.java new file mode 100644 index 0000000000..2c337f3cd7 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerWrappingEventSourceHealthIndicator.java @@ -0,0 +1,21 @@ +package io.javaoperatorsdk.operator.health; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public interface InformerWrappingEventSourceHealthIndicator + extends EventSourceHealthIndicator { + + Map informerHealthIndicators(); + + @Override + default Status getStatus() { + var nonUp = + informerHealthIndicators().values().stream() + .filter(i -> i.getStatus() != Status.HEALTHY) + .findAny(); + + return nonUp.isPresent() ? Status.UNHEALTHY : Status.HEALTHY; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/Status.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/Status.java new file mode 100644 index 0000000000..ec61251132 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/Status.java @@ -0,0 +1,11 @@ +package io.javaoperatorsdk.operator.health; + +/** + * The health status of an {@link io.javaoperatorsdk.operator.processing.event.source.EventSource} + */ +public enum Status { + HEALTHY, + UNHEALTHY, + /** For event sources where it cannot be determined if it is healthy ot not. */ + UNKNOWN +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java new file mode 100644 index 0000000000..a53d52c429 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java @@ -0,0 +1,489 @@ +package io.javaoperatorsdk.operator.processing; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.KubernetesResourceList; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.javaoperatorsdk.operator.CustomResourceUtils; +import io.javaoperatorsdk.operator.MissingCRDException; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.RegisteredController; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.ExecutorServiceManager; +import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; +import io.javaoperatorsdk.operator.api.monitoring.Metrics.ControllerExecution; +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ContextInitializer; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Ignore; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.dependent.EventSourceNotFoundException; +import io.javaoperatorsdk.operator.api.reconciler.dependent.EventSourceReferencer; +import io.javaoperatorsdk.operator.health.ControllerHealthInfo; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Workflow; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowCleanupResult; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowReconcileResult; +import io.javaoperatorsdk.operator.processing.event.EventProcessor; +import io.javaoperatorsdk.operator.processing.event.EventSourceManager; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_CURRENT_NAMESPACE; + +@SuppressWarnings({"unchecked", "rawtypes"}) +@Ignore +public class Controller

+ implements Reconciler

, LifecycleAware, Cleaner

, RegisteredController

{ + + private static final Logger log = LoggerFactory.getLogger(Controller.class); + private static final String CLEANUP = "cleanup"; + private static final String DELETE = "delete"; + private static final String FINALIZER_NOT_REMOVED = "finalizerNotRemoved"; + private static final String RECONCILE = "reconcile"; + private static final String RESOURCE = "resource"; + private static final String STATUS = "status"; + private static final String BOTH = "both"; + + private final Reconciler

reconciler; + private final ControllerConfiguration

configuration; + private final KubernetesClient kubernetesClient; + private final EventSourceManager

eventSourceManager; + private final boolean contextInitializer; + private final boolean isCleaner; + private final Metrics metrics; + private final Workflow

managedWorkflow; + private final boolean explicitWorkflowInvocation; + + private final GroupVersionKind associatedGVK; + private final EventProcessor

eventProcessor; + private final ControllerHealthInfo controllerHealthInfo; + private final EventSourceContext

eventSourceContext; + + public Controller( + Reconciler

reconciler, + ControllerConfiguration

configuration, + KubernetesClient kubernetesClient) { + // needs to be initialized early since it's used in other downstream classes + associatedGVK = GroupVersionKind.gvkFor(configuration.getResourceClass()); + + final var configurationService = configuration.getConfigurationService(); + this.reconciler = reconciler; + this.configuration = configuration; + this.kubernetesClient = kubernetesClient; + this.metrics = Optional.ofNullable(configurationService.getMetrics()).orElse(Metrics.NOOP); + contextInitializer = reconciler instanceof ContextInitializer; + isCleaner = reconciler instanceof Cleaner; + + final var managed = configurationService.getWorkflowFactory().workflowFor(configuration); + managedWorkflow = managed.resolve(kubernetesClient, configuration); + explicitWorkflowInvocation = + configuration.getWorkflowSpec().map(WorkflowSpec::isExplicitInvocation).orElse(false); + + eventSourceManager = new EventSourceManager<>(this); + eventProcessor = new EventProcessor<>(eventSourceManager, configurationService); + eventSourceManager.postProcessDefaultEventSourcesAfterProcessorInitializer(); + controllerHealthInfo = new ControllerHealthInfo(eventSourceManager); + eventSourceContext = + new EventSourceContext<>( + eventSourceManager.getControllerEventSource(), + configuration, + kubernetesClient, + configuration.getResourceClass()); + initAndRegisterEventSources(eventSourceContext); + configurationService.getMetrics().controllerRegistered(this); + } + + @Override + public UpdateControl

reconcile(P resource, Context

context) throws Exception { + return metrics.timeControllerExecution( + new ControllerExecution<>() { + @Override + public String name() { + return RECONCILE; + } + + @Override + public String controllerName() { + return configuration.getName(); + } + + @Override + public String successTypeName(UpdateControl

result) { + String successType = RESOURCE; + if (result.isPatchStatus()) { + successType = STATUS; + } + if (result.isPatchResourceAndStatus()) { + successType = BOTH; + } + return successType; + } + + @Override + public ResourceID resourceID() { + return ResourceID.fromResource(resource); + } + + @Override + public Map metadata() { + return Map.of(Constants.RESOURCE_GVK_KEY, associatedGVK); + } + + @Override + public UpdateControl

execute() throws Exception { + initContextIfNeeded(resource, context); + configuration + .getWorkflowSpec() + .ifPresent( + ws -> { + if (!managedWorkflow.isEmpty() && !explicitWorkflowInvocation) { + managedWorkflow.reconcile(resource, context); + } + }); + return reconciler.reconcile(resource, context); + } + }); + } + + @Override + public DeleteControl cleanup(P resource, Context

context) { + try { + return metrics.timeControllerExecution( + new ControllerExecution<>() { + @Override + public String name() { + return CLEANUP; + } + + @Override + public String controllerName() { + return configuration.getName(); + } + + @Override + public String successTypeName(DeleteControl deleteControl) { + return deleteControl.isRemoveFinalizer() ? DELETE : FINALIZER_NOT_REMOVED; + } + + @Override + public ResourceID resourceID() { + return ResourceID.fromResource(resource); + } + + @Override + public Map metadata() { + return Map.of(Constants.RESOURCE_GVK_KEY, associatedGVK); + } + + @Override + public DeleteControl execute() throws Exception { + initContextIfNeeded(resource, context); + WorkflowCleanupResult workflowCleanupResult = null; + + // The cleanup is called also when explicit invocation is true, but the cleaner is not + // implemented, also in case when explicit invocation is false, but there is cleaner + // implemented. + if (managedWorkflow.hasCleaner() && (!explicitWorkflowInvocation || !isCleaner)) { + workflowCleanupResult = managedWorkflow.cleanup(resource, context); + } + + if (isCleaner) { + var cleanupResult = ((Cleaner

) reconciler).cleanup(resource, context); + if (!cleanupResult.isRemoveFinalizer()) { + return cleanupResult; + } else { + // this means there is no reschedule + return workflowCleanupResultToDefaultDelete(workflowCleanupResult); + } + } else { + return workflowCleanupResultToDefaultDelete(workflowCleanupResult); + } + } + }); + } catch (Exception e) { + throw new OperatorException(e); + } + } + + private DeleteControl workflowCleanupResultToDefaultDelete( + WorkflowCleanupResult workflowCleanupResult) { + if (workflowCleanupResult == null) { + return DeleteControl.defaultDelete(); + } else { + return workflowCleanupResult.allPostConditionsMet() + ? DeleteControl.defaultDelete() + : DeleteControl.noFinalizerRemoval(); + } + } + + private void initContextIfNeeded(P resource, Context

context) { + if (contextInitializer) { + ((ContextInitializer

) reconciler).initContext(resource, context); + } + } + + public void initAndRegisterEventSources(EventSourceContext

context) { + final var ownSources = this.reconciler.prepareEventSources(context); + ownSources.forEach(eventSourceManager::registerEventSource); + + // register created event sources + final var dependentResourcesByName = + managedWorkflow.getDependentResourcesWithoutActivationCondition(); + final var size = dependentResourcesByName.size(); + if (size > 0) { + dependentResourcesByName.forEach( + dependentResource -> { + Optional eventSource = dependentResource.eventSource(context); + eventSource.ifPresent(eventSourceManager::registerEventSource); + }); + + // resolve event sources referenced by name for dependents that reuse an existing event source + final Map> unresolvable = new HashMap<>(size); + dependentResourcesByName.stream() + .filter(EventSourceReferencer.class::isInstance) + .map(EventSourceReferencer.class::cast) + .forEach( + dr -> { + try { + ((EventSourceReferencer

) dr).resolveEventSource(eventSourceManager); + } catch (EventSourceNotFoundException e) { + unresolvable + .computeIfAbsent(e.getEventSourceName(), s -> new ArrayList<>()) + .add(dr); + } + }); + if (!unresolvable.isEmpty()) { + throw new IllegalStateException( + "Couldn't resolve referenced EventSources: " + unresolvable); + } + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Controller that = (Controller) o; + return configuration.getName().equals(that.configuration.getName()); + } + + @Override + public int hashCode() { + return configuration.getName().hashCode(); + } + + @Override + public String toString() { + return "'" + configuration.getName() + "' Controller"; + } + + public Reconciler

getReconciler() { + return reconciler; + } + + public ControllerConfiguration

getConfiguration() { + return configuration; + } + + @Override + public ControllerHealthInfo getControllerHealthInfo() { + return controllerHealthInfo; + } + + public KubernetesClient getClient() { + return kubernetesClient; + } + + public MixedOperation, Resource

> getCRClient() { + return kubernetesClient.resources(configuration.getResourceClass()); + } + + public void start() throws OperatorException { + start(true); + } + + /** + * Registers the specified controller with this operator, overriding its default configuration by + * the specified one (usually created via {@link + * io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider#override(ControllerConfiguration)}, + * passing it the controller's original configuration. + * + * @param startEventProcessor if event processing should be started automatically + * @throws OperatorException if a problem occurred during the registration process + */ + public synchronized void start(boolean startEventProcessor) throws OperatorException { + final Class

resClass = configuration.getResourceClass(); + final String controllerName = configuration.getName(); + final var crdName = configuration.getResourceTypeName(); + final var specVersion = "v1"; + log.info( + "Starting '{}' controller for reconciler: {}, resource: {}", + controllerName, + configuration.getAssociatedReconcilerClassName(), + resClass.getCanonicalName()); + + // fail early if we're missing the current namespace information + failOnMissingCurrentNS(); + try { + // check that the custom resource is known by the cluster if configured that way + validateCRDWithLocalModelIfRequired(resClass, controllerName, crdName, specVersion); + eventSourceManager.start(); + if (startEventProcessor) { + eventProcessor.start(); + } + log.info("'{}' controller started", controllerName); + } catch (MissingCRDException e) { + stop(); + throwMissingCRDException(e.getCrdName(), e.getSpecVersion(), controllerName); + } + } + + private void validateCRDWithLocalModelIfRequired( + Class

resClass, String controllerName, String crdName, String specVersion) { + final CustomResourceDefinition crd; + if (getConfiguration().getConfigurationService().checkCRDAndValidateLocalModel() + && CustomResource.class.isAssignableFrom(resClass)) { + crd = + kubernetesClient.apiextensions().v1().customResourceDefinitions().withName(crdName).get(); + if (crd == null) { + throwMissingCRDException(crdName, specVersion, controllerName); + } + // Apply validations that are not handled by fabric8 + CustomResourceUtils.assertCustomResource(resClass, crd); + } + } + + public synchronized void changeNamespaces(Set namespaces) { + if (namespaces.contains(WATCH_CURRENT_NAMESPACE)) { + throw new OperatorException("Unexpected value in target namespaces: " + namespaces); + } + if (namespaces.contains(Constants.WATCH_ALL_NAMESPACES) && namespaces.size() > 1) { + throw new OperatorException( + "Watching all namespaces, but additional specific namespace is present"); + } + // if the processor was not running, for example because the controller + // was not leading in a HA setup, we don't want to stop and + // mainly start the processor on namespace change. + boolean eventProcessorWasRunning = eventProcessor.isRunning(); + if (eventProcessorWasRunning) { + eventProcessor.stop(); + } + eventSourceManager.changeNamespaces(namespaces); + if (eventProcessorWasRunning) { + eventProcessor.start(); + } + } + + public synchronized void startEventProcessing() { + eventProcessor.start(); + log.info("Started event processing for controller: {}", configuration.getName()); + } + + private void throwMissingCRDException(String crdName, String specVersion, String controllerName) { + throw new MissingCRDException( + crdName, + specVersion, + "'" + + crdName + + "' " + + specVersion + + " CRD was not found on the cluster, controller '" + + controllerName + + "' cannot be registered"); + } + + /** + * Throws an {@link OperatorException} if the controller is configured to watch the current + * namespace but it's absent from the configuration. + */ + private void failOnMissingCurrentNS() { + try { + configuration.getEffectiveNamespaces(); + } catch (OperatorException e) { + throw new OperatorException( + "Controller '" + + configuration.getName() + + "' is configured to watch the current namespace but it couldn't be inferred from" + + " the current configuration."); + } + } + + public EventSourceManager

getEventSourceManager() { + return eventSourceManager; + } + + public synchronized void stop() { + if (eventProcessor != null) { + eventProcessor.stop(); + } + if (eventSourceManager != null) { + eventSourceManager.stop(); + } + } + + public boolean useFinalizer() { + return isCleaner || managedWorkflow.hasCleaner(); + } + + public GroupVersionKind getAssociatedGroupVersionKind() { + return associatedGVK; + } + + public EventProcessor

getEventProcessor() { + return eventProcessor; + } + + public ExecutorServiceManager getExecutorServiceManager() { + return getConfiguration().getConfigurationService().getExecutorServiceManager(); + } + + public EventSourceContext

eventSourceContext() { + return eventSourceContext; + } + + public WorkflowReconcileResult reconcileManagedWorkflow(P primary, Context

context) { + if (!managedWorkflow.isEmpty()) { + return managedWorkflow.reconcile(primary, context); + } + return WorkflowReconcileResult.EMPTY; + } + + public WorkflowCleanupResult cleanupManagedWorkflow(P resource, Context

context) { + if (managedWorkflow.hasCleaner()) { + return managedWorkflow.cleanup(resource, context); + } + return WorkflowCleanupResult.EMPTY; + } + + public boolean isWorkflowExplicitInvocation() { + return explicitWorkflowInvocation; + } + + public boolean workflowContainsDependentForType(Class clazz) { + return managedWorkflow.getDependentResourcesByName().values().stream() + .anyMatch(d -> d.resourceType().equals(clazz)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/GroupVersionKind.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/GroupVersionKind.java new file mode 100644 index 0000000000..6c0a5c95ba --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/GroupVersionKind.java @@ -0,0 +1,124 @@ +package io.javaoperatorsdk.operator.processing; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public class GroupVersionKind { + private static final String SEPARATOR = "/"; + private final String group; + private final String version; + private final String kind; + private final String apiVersion; + protected static final Map, GroupVersionKind> CACHE = + new ConcurrentHashMap<>(); + + public GroupVersionKind(String apiVersion, String kind) { + this.kind = kind; + String[] groupAndVersion = apiVersion.split(SEPARATOR); + if (groupAndVersion.length == 1) { + this.group = null; + this.version = groupAndVersion[0]; + } else { + this.group = groupAndVersion[0]; + this.version = groupAndVersion[1]; + } + this.apiVersion = apiVersion; + } + + public static GroupVersionKind gvkFor(Class resourceClass) { + return CACHE.computeIfAbsent(resourceClass, GroupVersionKind::computeGVK); + } + + private static GroupVersionKind computeGVK(Class rc) { + return new GroupVersionKind( + HasMetadata.getGroup(rc), HasMetadata.getVersion(rc), HasMetadata.getKind(rc)); + } + + public GroupVersionKind(String group, String version, String kind) { + this.group = group; + this.version = version; + this.kind = kind; + this.apiVersion = (group == null || group.isBlank()) ? version : group + SEPARATOR + version; + } + + /** + * Parse GVK from a String representation. Expected format is: [group]/[version]/[kind] + * + *

+   *   Sample: "apps/v1/Deployment"
+   * 
+ * + * or: [version]/[kind] + * + *
+   *     Sample: v1/ConfigMap
+   * 
+ */ + public static GroupVersionKind fromString(String gvk) { + String[] parts = gvk.split(SEPARATOR); + if (parts.length == 3) { + return new GroupVersionKind(parts[0], parts[1], parts[2]); + } else if (parts.length == 2) { + return new GroupVersionKind(null, parts[0], parts[1]); + } else { + throw new IllegalArgumentException( + "Cannot parse gvk: " + gvk + ". Needs to be in form [group]/[version]/[kind]"); + } + } + + /** + * Reverse to {@link #fromString(String)}. + * + * @return gvk encoded in simple string. + */ + public String toGVKString() { + if (group != null) { + return group + SEPARATOR + version + SEPARATOR + kind; + } else { + return version + SEPARATOR + kind; + } + } + + public String getGroup() { + return group; + } + + public String getVersion() { + return version; + } + + public String getKind() { + return kind; + } + + public String apiVersion() { + return apiVersion; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof GroupVersionKind that)) return false; + return Objects.equals(apiVersion, that.apiVersion) + && Objects.equals(kind, that.kind) + && specificEquals(that) + && that.specificEquals(this); + } + + protected boolean specificEquals(GroupVersionKind that) { + return true; + } + + @Override + public int hashCode() { + return Objects.hash(apiVersion, kind); + } + + @Override + public String toString() { + return toGVKString(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/KubernetesResourceUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/KubernetesResourceUtils.java new file mode 100644 index 0000000000..f585974dff --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/KubernetesResourceUtils.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.processing; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public class KubernetesResourceUtils { + + public static String getName(HasMetadata resource) { + return resource.getMetadata().getName(); + } + + public static String getUID(HasMetadata resource) { + return resource.getMetadata().getUid(); + } + + public static String getVersion(HasMetadata resource) { + return resource.getMetadata().getResourceVersion(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/LifecycleAware.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/LifecycleAware.java new file mode 100644 index 0000000000..d96391dcd5 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/LifecycleAware.java @@ -0,0 +1,9 @@ +package io.javaoperatorsdk.operator.processing; + +import io.javaoperatorsdk.operator.OperatorException; + +public interface LifecycleAware { + void start() throws OperatorException; + + void stop() throws OperatorException; +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/LoggingUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/LoggingUtils.java new file mode 100644 index 0000000000..3b093cd818 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/LoggingUtils.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.processing; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.Secret; + +public class LoggingUtils { + + private LoggingUtils() {} + + public static boolean isNotSensitiveResource(HasMetadata resource) { + return !(resource instanceof Secret); + } +} 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 new file mode 100644 index 0000000000..12348ed932 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java @@ -0,0 +1,66 @@ +package io.javaoperatorsdk.operator.processing; + +import org.slf4j.MDC; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.Utils; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public class MDCUtils { + + private static final String NAME = "resource.name"; + private static final String NAMESPACE = "resource.namespace"; + private static final String API_VERSION = "resource.apiVersion"; + private static final String KIND = "resource.kind"; + 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); + + public static void addResourceIDInfo(ResourceID resourceID) { + if (enabled) { + MDC.put(NAME, resourceID.getName()); + MDC.put(NAMESPACE, resourceID.getNamespace().orElse(NO_NAMESPACE)); + } + } + + public static void removeResourceIDInfo() { + if (enabled) { + MDC.remove(NAME); + MDC.remove(NAMESPACE); + } + } + + public static void addResourceInfo(HasMetadata resource) { + if (enabled) { + MDC.put(API_VERSION, resource.getApiVersion()); + MDC.put(KIND, 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()); + if (metadata.getGeneration() != null) { + MDC.put(GENERATION, metadata.getGeneration().toString()); + } + MDC.put(UID, metadata.getUid()); + } + } + } + + public static void removeResourceInfo() { + 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); + } + } +} 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 new file mode 100644 index 0000000000..9471d52cc4 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResource.java @@ -0,0 +1,247 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import java.util.Optional; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.Ignore; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.NameSetter; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; +import io.javaoperatorsdk.operator.processing.dependent.Matcher.Result; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +/** + * An abstract implementation of {@link DependentResource} to be used as base for custom + * implementations, providing, in particular, the core {@link #reconcile(HasMetadata, Context)} + * logic for dependents + * + * @param the dependent resource type + * @param

the associated primary resource type + */ +@Ignore +public abstract class AbstractDependentResource + implements DependentResource, NameSetter { + private static final Logger log = LoggerFactory.getLogger(AbstractDependentResource.class); + + private final boolean creatable = this instanceof Creator; + private final boolean updatable = this instanceof Updater; + private final boolean deletable = this instanceof Deleter; + private final DependentResourceReconciler dependentResourceReconciler; + protected Creator creator; + protected Updater updater; + protected String name; + + protected AbstractDependentResource() { + this(null); + } + + @SuppressWarnings("unchecked") + protected AbstractDependentResource(String name) { + creator = creatable ? (Creator) this : null; + updater = updatable ? (Updater) this : null; + + dependentResourceReconciler = + this instanceof BulkDependentResource + ? new BulkDependentResourceReconciler<>((BulkDependentResource) this) + : new SingleDependentResourceReconciler<>(this); + this.name = name == null ? DependentResource.defaultNameFor(this.getClass()) : name; + } + + /** + * Overriding classes are strongly encouraged to call this implementation as part of their + * implementation, as they otherwise risk breaking functionality. + * + * @param primary the primary resource for which we want to reconcile the dependent state + * @param context {@link Context} providing useful contextual information + * @return the reconciliation result + */ + @Override + public ReconcileResult reconcile(P primary, Context

context) { + return dependentResourceReconciler.reconcile(primary, context); + } + + protected ReconcileResult reconcile(P primary, R actualResource, Context

context) { + if (creatable() || updatable()) { + if (actualResource == null) { + if (creatable) { + var desired = desired(primary, context); + throwIfNull(desired, primary, "Desired"); + logForOperation("Creating", primary, desired); + var createdResource = handleCreate(desired, primary, context); + return ReconcileResult.resourceCreated(createdResource); + } + } else { + if (updatable()) { + final Matcher.Result match = match(actualResource, primary, context); + if (!match.matched()) { + final var desired = match.computedDesired().orElseGet(() -> desired(primary, context)); + throwIfNull(desired, primary, "Desired"); + logForOperation("Updating", primary, desired); + var updatedResource = handleUpdate(actualResource, desired, primary, context); + return ReconcileResult.resourceUpdated(updatedResource); + } else { + log.debug( + "Update skipped for dependent {} as it matched the existing one", + actualResource instanceof HasMetadata + ? ResourceID.fromResource((HasMetadata) actualResource) + : getClass().getSimpleName()); + } + } else { + log.debug( + "Update skipped for dependent {} implement Updater interface to modify it", + actualResource instanceof HasMetadata + ? ResourceID.fromResource((HasMetadata) actualResource) + : getClass().getSimpleName()); + } + } + } else { + log.debug( + "Dependent {} is read-only, implement Creator and/or Updater interfaces to modify it", + getClass().getSimpleName()); + } + return ReconcileResult.noOperation(actualResource); + } + + public abstract Result match(R resource, P primary, Context

context); + + @Override + public Optional getSecondaryResource(P primary, Context

context) { + + var secondaryResources = context.getSecondaryResources(resourceType()); + if (secondaryResources.isEmpty()) { + return Optional.empty(); + } else { + return selectTargetSecondaryResource(secondaryResources, primary, context); + } + } + + /** + * Selects the actual secondary resource matching the desired state derived from the primary + * resource when several resources of the same type are found in the context. This method allows + * for optimized implementations in subclasses since this default implementation will check each + * secondary candidates for equality with the specified desired state, which might end up costly. + * + * @param secondaryResources to select the target resource from + * @param primary the primary resource + * @param context the context in which this method is called + * @return the matching secondary resource or {@link Optional#empty()} if none matches + * @throws IllegalStateException if more than one candidate is found, in which case some other + * mechanism might be necessary to distinguish between candidate secondary resources + */ + protected Optional selectTargetSecondaryResource( + Set secondaryResources, P primary, Context

context) { + R desired = desired(primary, context); + var targetResources = secondaryResources.stream().filter(r -> r.equals(desired)).toList(); + if (targetResources.size() > 1) { + throw new IllegalStateException( + "More than one secondary resource related to primary: " + targetResources); + } + return targetResources.isEmpty() ? Optional.empty() : Optional.of(targetResources.get(0)); + } + + private void throwIfNull(R desired, P primary, String descriptor) { + if (desired == null) { + throw new DependentResourceException( + descriptor + " cannot be null. Primary ID: " + ResourceID.fromResource(primary)); + } + } + + private void logForOperation(String operation, P primary, R desired) { + final var desiredDesc = + desired instanceof HasMetadata + ? "'" + + ((HasMetadata) desired).getMetadata().getName() + + "' " + + ((HasMetadata) desired).getKind() + : desired.getClass().getSimpleName(); + log.debug("{} {} for primary {}", operation, desiredDesc, ResourceID.fromResource(primary)); + } + + protected R handleCreate(R desired, P primary, Context

context) { + R created = creator.create(desired, primary, context); + throwIfNull(created, primary, "Created resource"); + onCreated(primary, created, context); + return created; + } + + /** + * Allows subclasses to perform additional processing (e.g. caching) on the created resource if + * needed. + * + * @param primary the {@link ResourceID} of the primary resource associated with the newly created + * resource + * @param created the newly created resource + * @param context the context in which this operation is called + */ + protected abstract void onCreated(P primary, R created, Context

context); + + /** + * Allows subclasses to perform additional processing on the updated resource if needed. + * + * @param primary the {@link ResourceID} of the primary resource associated with the newly updated + * resource + * @param updated the updated resource + * @param actual the resource as it was before the update + * @param context the context in which this operation is called + */ + protected abstract void onUpdated(P primary, R updated, R actual, Context

context); + + protected R handleUpdate(R actual, R desired, P primary, Context

context) { + R updated = updater.update(actual, desired, primary, context); + throwIfNull(updated, primary, "Updated resource"); + onUpdated(primary, updated, actual, context); + return updated; + } + + protected R desired(P primary, Context

context) { + throw new IllegalStateException( + "desired method must be implemented if this DependentResource can be created and/or" + + " updated"); + } + + public void delete(P primary, Context

context) { + dependentResourceReconciler.delete(primary, context); + } + + protected void handleDelete(P primary, R secondary, Context

context) { + throw new IllegalStateException( + "handleDelete method must be implemented if Deleter trait is supported"); + } + + protected boolean isCreatable() { + return creatable; + } + + @SuppressWarnings("unused") + protected boolean isUpdatable() { + return updatable; + } + + @Override + public boolean isDeletable() { + return deletable; + } + + @Override + public String name() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + protected boolean creatable() { + return creatable; + } + + protected boolean updatable() { + return updatable; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractEventSourceHolderDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractEventSourceHolderDependentResource.java new file mode 100644 index 0000000000..7f2674892f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractEventSourceHolderDependentResource.java @@ -0,0 +1,129 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.Utils; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Ignore; +import io.javaoperatorsdk.operator.api.reconciler.dependent.EventSourceNotFoundException; +import io.javaoperatorsdk.operator.api.reconciler.dependent.EventSourceReferencer; +import io.javaoperatorsdk.operator.api.reconciler.dependent.RecentOperationCacheFiller; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; + +@Ignore +public abstract class AbstractEventSourceHolderDependentResource< + R, P extends HasMetadata, T extends EventSource> + extends AbstractDependentResource implements EventSourceReferencer

{ + + private T eventSource; + private final Class resourceType; + private boolean isCacheFillerEventSource; + protected String eventSourceNameToUse; + + @SuppressWarnings("unchecked") + protected AbstractEventSourceHolderDependentResource() { + this(null, null); + } + + protected AbstractEventSourceHolderDependentResource(Class resourceType) { + this(resourceType, null); + } + + protected AbstractEventSourceHolderDependentResource(Class resourceType, String name) { + super(name); + if (resourceType == null) { + this.resourceType = (Class) Utils.getTypeArgumentFromHierarchyByIndex(getClass(), 0); + } else { + this.resourceType = resourceType; + } + } + + /** + * Method is synchronized since when used in case of dynamic registration (thus for activation + * conditions) can be called concurrently to create the target event source. In that case only one + * instance should be created, since this also sets the event source, and dynamic registration + * will just start one with the same name. So if this would not be synchronized it could happen + * that multiple event sources would be created and only one started and registered. Note that + * this method does not start the event source, so no blocking IO is involved. + */ + public synchronized Optional eventSource(EventSourceContext

context) { + // some sub-classes (e.g. KubernetesDependentResource) can have their event source created + // before this method is called in the managed case, so only create the event source if it + // hasn't already been set. + // The filters are applied automatically only if event source is created automatically. So if an + // event source + // is shared between dependent resources this does not override the existing filters. + + if (eventSource == null && eventSourceNameToUse == null) { + setEventSource(createEventSource(context)); + } + return Optional.ofNullable(eventSource); + } + + @SuppressWarnings("unchecked") + @Override + public void resolveEventSource(EventSourceRetriever

eventSourceRetriever) { + if (eventSourceNameToUse != null && eventSource == null) { + final var source = + eventSourceRetriever.getEventSourceFor(resourceType(), eventSourceNameToUse); + if (source == null) { + throw new EventSourceNotFoundException(eventSourceNameToUse); + } + setEventSource((T) source); + } + } + + /** + * To make this backwards compatible even for respect of overriding + * + * @param context for event sources + * @return event source instance + */ + public T initEventSource(EventSourceContext

context) { + return eventSource(context).orElseThrow(); + } + + @Override + public void useEventSourceWithName(String name) { + this.eventSourceNameToUse = name; + } + + @Override + public Class resourceType() { + return resourceType; + } + + protected abstract T createEventSource(EventSourceContext

context); + + public void setEventSource(T eventSource) { + isCacheFillerEventSource = eventSource instanceof RecentOperationCacheFiller; + this.eventSource = eventSource; + } + + public Optional eventSource() { + return Optional.ofNullable(eventSource); + } + + protected void onCreated(P primary, R created, Context

context) { + if (isCacheFillerEventSource) { + recentOperationCacheFiller() + .handleRecentResourceCreate(ResourceID.fromResource(primary), created); + } + } + + protected void onUpdated(P primary, R updated, R actual, Context

context) { + if (isCacheFillerEventSource) { + recentOperationCacheFiller() + .handleRecentResourceUpdate(ResourceID.fromResource(primary), updated, actual); + } + } + + @SuppressWarnings("unchecked") + private RecentOperationCacheFiller recentOperationCacheFiller() { + return (RecentOperationCacheFiller) eventSource; + } +} 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 new file mode 100644 index 0000000000..4c828b7eb9 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/AbstractExternalDependentResource.java @@ -0,0 +1,109 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.RecentOperationCacheFiller; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +public abstract class AbstractExternalDependentResource< + R, P extends HasMetadata, T extends EventSource> + extends AbstractEventSourceHolderDependentResource { + + private final boolean isDependentResourceWithExplicitState = + this instanceof DependentResourceWithExplicitState; + private final boolean isBulkDependentResource = this instanceof BulkDependentResource; + + @SuppressWarnings("rawtypes") + private DependentResourceWithExplicitState dependentResourceWithExplicitState; + + private InformerEventSource externalStateEventSource; + + protected AbstractExternalDependentResource() {} + + @SuppressWarnings("unchecked") + protected AbstractExternalDependentResource(Class resourceType) { + super(resourceType); + if (isDependentResourceWithExplicitState) { + dependentResourceWithExplicitState = (DependentResourceWithExplicitState) this; + } + } + + @Override + @SuppressWarnings("unchecked") + public void resolveEventSource(EventSourceRetriever

eventSourceRetriever) { + super.resolveEventSource(eventSourceRetriever); + if (isDependentResourceWithExplicitState) { + final var eventSourceName = + (String) dependentResourceWithExplicitState.eventSourceName().orElse(null); + externalStateEventSource = + (InformerEventSource) + eventSourceRetriever.getEventSourceFor( + dependentResourceWithExplicitState.stateResourceClass(), eventSourceName); + } + } + + @Override + protected void onCreated(P primary, R created, Context

context) { + super.onCreated(primary, created, context); + if (this instanceof DependentResourceWithExplicitState) { + handleExplicitStateCreation(primary, created, context); + } + } + + @Override + public void delete(P primary, Context

context) { + if (isDependentResourceWithExplicitState && !isBulkDependentResource) { + var secondary = getSecondaryResource(primary, context); + super.delete(primary, context); + // deletes the state after the resource is deleted + handleExplicitStateDelete(primary, secondary.orElse(null), context); + } else { + super.delete(primary, context); + } + } + + @SuppressWarnings({"unchecked", "unused"}) + private void handleExplicitStateDelete(P primary, R secondary, Context

context) { + var res = dependentResourceWithExplicitState.stateResource(primary, secondary); + context.getClient().resource(res).delete(); + } + + @SuppressWarnings({"rawtypes", "unchecked", "unused"}) + protected void handleExplicitStateCreation(P primary, R created, Context

context) { + var resource = dependentResourceWithExplicitState.stateResource(primary, created); + var stateResource = context.getClient().resource(resource).create(); + if (externalStateEventSource != null) { + ((RecentOperationCacheFiller) externalStateEventSource) + .handleRecentResourceCreate(ResourceID.fromResource(primary), stateResource); + } + } + + @Override + public Matcher.Result match(R resource, P primary, Context

context) { + var desired = desired(primary, context); + return Matcher.Result.computed(resource.equals(desired), desired); + } + + @SuppressWarnings("unchecked") + public void deleteTargetResource(P primary, R resource, String key, Context

context) { + if (isDependentResourceWithExplicitState) { + context + .getClient() + .resource(dependentResourceWithExplicitState.stateResource(primary, resource)) + .delete(); + } + handleDeleteTargetResource(primary, resource, key, context); + } + + public void handleDeleteTargetResource(P primary, R resource, String key, Context

context) { + throw new IllegalStateException("Override this method in case you manage an bulk resource"); + } + + @SuppressWarnings("rawtypes") + protected InformerEventSource getExternalStateEventSource() { + return externalStateEventSource; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResource.java new file mode 100644 index 0000000000..4f55041c04 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResource.java @@ -0,0 +1,76 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.processing.dependent.Matcher.Result; + +/** + * Manages dynamic number of resources created for a primary resource. A dependent resource + * implementing this interface will typically also implement one or more additional interfaces such + * as {@link Creator}, {@link Updater}, {@link Deleter}. + * + * @param the dependent resource type + * @param

the primary resource type + */ +public interface BulkDependentResource { + + /** + * Retrieves a map of desired secondary resources associated with the specified primary resource, + * identified by an arbitrary key. + * + * @param primary the primary resource with which we want to identify which secondary resources + * are associated + * @param context the {@link Context} associated with the current reconciliation + * @return a Map associating desired secondary resources with the specified primary via arbitrary + * identifiers + */ + default Map desiredResources(P primary, Context

context) { + throw new IllegalStateException( + "Implement desiredResources in case a non read-only bulk dependent resource"); + } + + /** + * Retrieves the actual secondary resources currently existing on the server and associated with + * the specified primary resource. + * + * @param primary the primary resource for which we want to retrieve the associated secondary + * resources + * @param context the {@link Context} associated with the current reconciliation + * @return a Map associating actual secondary resources with the specified primary via arbitrary + * identifiers + */ + Map getSecondaryResources(P primary, Context

context); + + /** + * Deletes the actual resource identified by the specified key if it's not in the set of desired + * secondary resources for the specified primary. + * + * @param primary the primary resource for which we want to remove now undesired secondary + * resources still present on the cluster + * @param resource the actual resource existing on the cluster that needs to be removed + * @param key key of the resource + * @param context actual context + */ + void deleteTargetResource(P primary, R resource, String key, Context

context); + + /** + * Determines whether the specified secondary resource matches the desired state with target index + * of a bulk resource as defined from the specified primary resource, given the specified {@link + * Context}. + * + * @param actualResource the resource we want to determine whether it's matching the desired state + * @param desired the resource's desired state + * @param primary the primary resource from which the desired state is inferred + * @param context the context in which the resource is being matched + * @return a {@link Result} encapsulating whether the resource matched its desired state and this + * associated state if it was computed as part of the matching process. Use the static + * convenience methods ({@link Result#nonComputed(boolean)} and {@link + * Result#computed(boolean, Object)}) + */ + default Result match(R actualResource, R desired, P primary, Context

context) { + return Matcher.Result.computed(desired.equals(actualResource), 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 new file mode 100644 index 0000000000..ebb47fd355 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java @@ -0,0 +1,143 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +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 { + + private final BulkDependentResource bulkDependentResource; + + BulkDependentResourceReconciler(BulkDependentResource bulkDependentResource) { + this.bulkDependentResource = bulkDependentResource; + } + + @Override + public ReconcileResult reconcile(P primary, Context

context) { + + Map actualResources = bulkDependentResource.getSecondaryResources(primary, context); + if (!(bulkDependentResource instanceof Creator) + && !(bulkDependentResource instanceof Deleter) + && !(bulkDependentResource instanceof Updater)) { + return ReconcileResult.aggregatedResult( + actualResources.values().stream().map(ReconcileResult::noOperation).toList()); + } + + final var desiredResources = bulkDependentResource.desiredResources(primary, context); + + if (bulkDependentResource instanceof Deleter) { + // remove existing resources that are not needed anymore according to the primary state + deleteExtraResources(desiredResources.keySet(), actualResources, primary, context); + } + + final List> results = new ArrayList<>(desiredResources.size()); + desiredResources.forEach( + (key, value) -> { + final var instance = new BulkDependentResourceInstance<>(bulkDependentResource, value); + results.add(instance.reconcile(primary, actualResources.get(key), context)); + }); + + return ReconcileResult.aggregatedResult(results); + } + + @Override + public void delete(P primary, Context

context) { + var actualResources = bulkDependentResource.getSecondaryResources(primary, context); + deleteExtraResources(Collections.emptySet(), actualResources, primary, context); + } + + private void deleteExtraResources( + Set expectedKeys, Map actualResources, P primary, Context

context) { + actualResources.forEach( + (key, value) -> { + if (!expectedKeys.contains(key)) { + bulkDependentResource.deleteTargetResource(primary, value, key, context); + } + }); + } + + /** + * Exposes a dynamically-created instance of the bulk dependent resource precursor as an + * AbstractDependentResource so that we can reuse its reconciliation logic. + * + * @param + * @param

+ */ + @Ignore + private static class BulkDependentResourceInstance + extends AbstractDependentResource implements Creator, Deleter

, Updater { + private final BulkDependentResource bulkDependentResource; + private final R desired; + + private BulkDependentResourceInstance( + BulkDependentResource bulkDependentResource, R desired) { + this.bulkDependentResource = bulkDependentResource; + this.desired = desired; + } + + @SuppressWarnings("unchecked") + private AbstractDependentResource asAbstractDependentResource() { + return (AbstractDependentResource) bulkDependentResource; + } + + @Override + protected R desired(P primary, Context

context) { + return desired; + } + + @SuppressWarnings("unchecked") + public R update(R actual, R desired, P primary, Context

context) { + return ((Updater) bulkDependentResource).update(actual, desired, primary, context); + } + + @Override + public Result match(R resource, P primary, Context

context) { + return bulkDependentResource.match(resource, desired, primary, context); + } + + @Override + protected void onCreated(P primary, R created, Context

context) { + asAbstractDependentResource().onCreated(primary, created, context); + } + + @Override + protected void onUpdated(P primary, R updated, R actual, Context

context) { + asAbstractDependentResource().onUpdated(primary, updated, actual, context); + } + + @Override + public Class resourceType() { + return asAbstractDependentResource().resourceType(); + } + + @SuppressWarnings("unchecked") + public R create(R desired, P primary, Context

context) { + return ((Creator) bulkDependentResource).create(desired, primary, context); + } + + @Override + protected boolean isCreatable() { + return bulkDependentResource instanceof Creator; + } + + @Override + protected boolean isUpdatable() { + return bulkDependentResource instanceof Updater; + } + + @Override + public boolean isDeletable() { + return bulkDependentResource instanceof Deleter; + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkUpdater.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkUpdater.java new file mode 100644 index 0000000000..67fe71b197 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkUpdater.java @@ -0,0 +1,28 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +/** + * Helper for the buld Dependent Resources to make it more explicit that such dependents only to + * implement {@link BulkDependentResource#match(Object,Object,HasMetadata,Context)} + * + * @param secondary resource type + * @param

primary resource type + */ +public interface BulkUpdater extends Updater { + + default Matcher.Result match(R actualResource, P primary, Context

context) { + if (!(this instanceof BulkDependentResource)) { + throw new IllegalStateException( + BulkUpdater.class.getSimpleName() + + " interface should only be implemented by " + + BulkDependentResource.class.getSimpleName() + + " implementations"); + } + throw new IllegalStateException( + "This method should not be called from a " + + BulkDependentResource.class.getSimpleName() + + " implementation"); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/CRUDBulkDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/CRUDBulkDependentResource.java new file mode 100644 index 0000000000..6e86f04a5c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/CRUDBulkDependentResource.java @@ -0,0 +1,7 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; + +public interface CRUDBulkDependentResource + extends BulkDependentResource, Creator, BulkUpdater, Deleter

{} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Creator.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Creator.java new file mode 100644 index 0000000000..b4143906bd --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Creator.java @@ -0,0 +1,9 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +@FunctionalInterface +public interface Creator { + R create(R desired, P primary, Context

context); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DependentResourceException.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DependentResourceException.java new file mode 100644 index 0000000000..a69eb1e0a8 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DependentResourceException.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import io.javaoperatorsdk.operator.OperatorException; + +public class DependentResourceException extends OperatorException { + + public DependentResourceException() {} + + public DependentResourceException(String message) { + super(message); + } + + public DependentResourceException(Throwable cause) { + super(cause); + } + + public DependentResourceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DependentResourceReconciler.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DependentResourceReconciler.java new file mode 100644 index 0000000000..ff687ff474 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DependentResourceReconciler.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; + +/** + * An internal interface that abstracts away how to reconcile dependent resources, in particular + * when they can be dynamically created based on the state provided by the primary resource (e.g. + * the primary resource dictates which/how many secondary resources need to be created). + * + * @param the type of the secondary resource to be reconciled + * @param

the primary resource type + */ +interface DependentResourceReconciler { + + ReconcileResult reconcile(P primary, Context

context); + + void delete(P primary, Context

context); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DependentResourceWithExplicitState.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DependentResourceWithExplicitState.java new file mode 100644 index 0000000000..7c3fff3c49 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DependentResourceWithExplicitState.java @@ -0,0 +1,47 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; + +/** + * Handles external resources where in order to address the resource additional information or + * persistent state (usually the ID of the resource) is needed to access the current state. These + * are non Kubernetes resources which when created their ID is generated, so cannot be determined + * based only on primary resources. In order to manage such dependent resource use this interface + * for a resource that extends {@link AbstractExternalDependentResource}. + * + * @param the dependent resource type + * @param

the primary resource type + * @param the state type + */ +public interface DependentResourceWithExplicitState + extends Creator, Deleter

{ + + /** + * Only needs to be implemented if multiple event sources are present for the target resource + * class. + * + * @return name of the event source to access the state resources. + */ + default Optional eventSourceName() { + return Optional.empty(); + } + + /** + * Class of the state resource. + * + * @return the type of the resource that stores state + */ + Class stateResourceClass(); + + /** + * State resource which contains the target state. Usually an ID to address the resource + * + * @param primary resource + * @param resource secondary resource + * @return that stores state + */ + S stateResource(P primary, R resource); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Matcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Matcher.java new file mode 100644 index 0000000000..286bef86c5 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Matcher.java @@ -0,0 +1,98 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +/** + * Implement this interface to provide custom matching logic when determining whether secondary + * resources match their desired state. This is used by some default implementations of the {@link + * io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource} interface, notably {@link + * io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource}. + * + * @param the type associated with the secondary resources we want to match + * @param

the type associated with the primary resources with which the related {@link + * io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource} implementation is + * associated + */ +public interface Matcher { + + /** + * Abstracts the matching result letting implementations also return the desired state if it has + * been computed as part of their logic. This allows the SDK to avoid re-computing it if not + * needed. + * + * @param the type associated with the secondary resources we want to match + */ + interface Result { + + /** + * Whether or not the actual resource matched the desired state + * + * @return {@code true} if the observed resource matched the desired state, {@code false} + * otherwise + */ + boolean matched(); + + /** + * Retrieves the associated desired state if it has been computed during the matching process or + * empty if not. + * + * @return an {@link Optional} holding the desired state if it has been computed during the + * matching process or {@link Optional#empty()} if not + */ + default Optional computedDesired() { + return Optional.empty(); + } + + /** + * Creates a result stating only whether the resource matched the desired state without having + * computed the desired state. + * + * @param matched whether the actual resource matched the desired state + * @return a {@link Result} with an empty computed desired state + * @param the type of resources being matched + */ + static Result nonComputed(boolean matched) { + return () -> matched; + } + + /** + * Creates a result stating whether the resource matched and the associated computed desired + * state so that the SDK can use it downstream without having to recompute it. + * + * @param matched whether the actual resource matched the desired state + * @param computedDesired the associated desired state as computed during the matching process + * @return a {@link Result} with the associated desired state + * @param the type of resources being matched + */ + static Result computed(boolean matched, T computedDesired) { + return new Result<>() { + @Override + public boolean matched() { + return matched; + } + + @Override + public Optional computedDesired() { + return Optional.of(computedDesired); + } + }; + } + } + + /** + * Determines whether the specified secondary resource matches the desired state as defined from + * the specified primary resource, given the specified {@link Context}. + * + * @param actualResource the resource we want to determine whether it's matching the desired state + * @param primary the primary resource from which the desired state is inferred + * @param context the context in which the resource is being matched + * @return a {@link Result} encapsulating whether the resource matched its desired state and this + * associated state if it was computed as part of the matching process. Use the static + * convenience methods ({@link Result#nonComputed(boolean)} and {@link + * Result#computed(boolean, Object)}) to create your return {@link Result}. + */ + Result match(R actualResource, P primary, Context

context); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/SingleDependentResourceReconciler.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/SingleDependentResourceReconciler.java new file mode 100644 index 0000000000..2862170c22 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/SingleDependentResourceReconciler.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; + +class SingleDependentResourceReconciler + implements DependentResourceReconciler { + + private final AbstractDependentResource instance; + + SingleDependentResourceReconciler(AbstractDependentResource dependentResource) { + this.instance = dependentResource; + } + + @Override + public ReconcileResult reconcile(P primary, Context

context) { + final var maybeActual = instance.getSecondaryResource(primary, context); + return instance.reconcile(primary, maybeActual.orElse(null), context); + } + + @Override + public void delete(P primary, Context

context) { + var secondary = instance.getSecondaryResource(primary, context); + instance.handleDelete(primary, secondary.orElse(null), context); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Updater.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Updater.java new file mode 100644 index 0000000000..8780022a73 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Updater.java @@ -0,0 +1,9 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +public interface Updater extends Matcher { + + R update(R actual, R desired, P primary, Context

context); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/external/AbstractPollingDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/external/AbstractPollingDependentResource.java new file mode 100644 index 0000000000..3cf93cba53 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/external/AbstractPollingDependentResource.java @@ -0,0 +1,43 @@ +package io.javaoperatorsdk.operator.processing.dependent.external; + +import java.time.Duration; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Ignore; +import io.javaoperatorsdk.operator.processing.dependent.AbstractExternalDependentResource; +import io.javaoperatorsdk.operator.processing.event.source.CacheKeyMapper; +import io.javaoperatorsdk.operator.processing.event.source.ExternalResourceCachingEventSource; + +@Ignore +public abstract class AbstractPollingDependentResource + extends AbstractExternalDependentResource> + implements CacheKeyMapper { + + public static final Duration DEFAULT_POLLING_PERIOD = Duration.ofMillis(5000); + private Duration pollingPeriod; + + protected AbstractPollingDependentResource() {} + + protected AbstractPollingDependentResource(Class resourceType) { + this(resourceType, DEFAULT_POLLING_PERIOD); + } + + public AbstractPollingDependentResource(Class resourceType, Duration pollingPeriod) { + super(resourceType); + this.pollingPeriod = pollingPeriod; + } + + public void setPollingPeriod(Duration pollingPeriod) { + this.pollingPeriod = pollingPeriod; + } + + public Duration getPollingPeriod() { + return pollingPeriod; + } + + // for now dependent resources support event sources only with one owned resource. + @Override + public String keyFor(R resource) { + return CacheKeyMapper.singleResourceCacheKeyMapper().keyFor(resource); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/external/PerResourcePollingDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/external/PerResourcePollingDependentResource.java new file mode 100644 index 0000000000..c0181207d8 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/external/PerResourcePollingDependentResource.java @@ -0,0 +1,39 @@ +package io.javaoperatorsdk.operator.processing.dependent.external; + +import java.time.Duration; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Ignore; +import io.javaoperatorsdk.operator.processing.event.source.ExternalResourceCachingEventSource; +import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingConfigurationBuilder; +import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingEventSource; + +@Ignore +public abstract class PerResourcePollingDependentResource + extends AbstractPollingDependentResource + implements PerResourcePollingEventSource.ResourceFetcher { + + public PerResourcePollingDependentResource() {} + + public PerResourcePollingDependentResource(Class resourceType) { + super(resourceType); + } + + public PerResourcePollingDependentResource(Class resourceType, Duration pollingPeriod) { + super(resourceType, pollingPeriod); + } + + @Override + protected ExternalResourceCachingEventSource createEventSource( + EventSourceContext

context) { + + return new PerResourcePollingEventSource<>( + resourceType(), + context, + new PerResourcePollingConfigurationBuilder<>(this, getPollingPeriod()) + .withCacheKeyMapper(this) + .withName(name()) + .build()); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/external/PollingDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/external/PollingDependentResource.java new file mode 100644 index 0000000000..674cbdd906 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/external/PollingDependentResource.java @@ -0,0 +1,38 @@ +package io.javaoperatorsdk.operator.processing.dependent.external; + +import java.time.Duration; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Ignore; +import io.javaoperatorsdk.operator.processing.event.source.CacheKeyMapper; +import io.javaoperatorsdk.operator.processing.event.source.ExternalResourceCachingEventSource; +import io.javaoperatorsdk.operator.processing.event.source.polling.PollingConfiguration; +import io.javaoperatorsdk.operator.processing.event.source.polling.PollingEventSource; + +@Ignore +public abstract class PollingDependentResource + extends AbstractPollingDependentResource + implements PollingEventSource.GenericResourceFetcher { + + private final CacheKeyMapper cacheKeyMapper; + + public PollingDependentResource(Class resourceType, CacheKeyMapper cacheKeyMapper) { + super(resourceType); + this.cacheKeyMapper = cacheKeyMapper; + } + + public PollingDependentResource( + Class resourceType, Duration pollingPeriod, CacheKeyMapper cacheKeyMapper) { + super(resourceType, pollingPeriod); + this.cacheKeyMapper = cacheKeyMapper; + } + + @Override + protected ExternalResourceCachingEventSource createEventSource( + EventSourceContext

context) { + return new PollingEventSource<>( + resourceType(), + new PollingConfiguration<>(name(), this, getPollingPeriod(), cacheKeyMapper)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/BooleanWithUndefined.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/BooleanWithUndefined.java new file mode 100644 index 0000000000..ee59a1c487 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/BooleanWithUndefined.java @@ -0,0 +1,19 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +/** A replacement for {@link Boolean}, which can't be used in annotations. */ +public enum BooleanWithUndefined { + TRUE, + FALSE, + UNDEFINED; + + public Boolean asBoolean() { + switch (this) { + case TRUE: + return Boolean.TRUE; + case FALSE: + return Boolean.FALSE; + default: + return null; + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/CRUDKubernetesDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/CRUDKubernetesDependentResource.java new file mode 100644 index 0000000000..392ac6d894 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/CRUDKubernetesDependentResource.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Ignore; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Updater; + +/** + * Adaptor class resources that manage Create, Read and Update operations and that should be + * automatically garbage-collected by Kubernetes when the associated primary resource is destroyed. + * + * @param the type of the managed dependent resource + * @param

the type of the associated primary resource + */ +@Ignore +public abstract class CRUDKubernetesDependentResource + extends KubernetesDependentResource + implements Creator, Updater, GarbageCollected

{ + + public CRUDKubernetesDependentResource() {} + + public CRUDKubernetesDependentResource(Class resourceType) { + super(resourceType); + } + + public CRUDKubernetesDependentResource(Class resourceType, String name) { + super(resourceType, name); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/CRUDNoGCKubernetesDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/CRUDNoGCKubernetesDependentResource.java new file mode 100644 index 0000000000..3b3c11b006 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/CRUDNoGCKubernetesDependentResource.java @@ -0,0 +1,28 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Ignore; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Updater; + +/** + * Adaptor class resources that manage Create, Read and Update operations, however resource is NOT + * garbage collected by Kubernetes when the associated primary resource is destroyed, instead + * explicitly deleted. This is useful when resource needs to be deleted before another one in a + * workflow, in other words an ordering matters during a cleanup. See also: Related issue + * + * @param the type of the managed dependent resource + * @param

the type of the associated primary resource + */ +@Ignore +public class CRUDNoGCKubernetesDependentResource + extends KubernetesDependentResource implements Creator, Updater, Deleter

{ + + public CRUDNoGCKubernetesDependentResource() {} + + public CRUDNoGCKubernetesDependentResource(Class resourceType) { + super(resourceType); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesDependentResource.java new file mode 100644 index 0000000000..3ed1fd5c4d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesDependentResource.java @@ -0,0 +1,42 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; + +public class GenericKubernetesDependentResource

+ extends KubernetesDependentResource { + + private final GroupVersionKindPlural groupVersionKind; + + public GenericKubernetesDependentResource(GroupVersionKind groupVersionKind) { + this(GroupVersionKindPlural.from(groupVersionKind)); + } + + public GenericKubernetesDependentResource(GroupVersionKind groupVersionKind, String name) { + this(GroupVersionKindPlural.from(groupVersionKind), name); + } + + public GenericKubernetesDependentResource(GroupVersionKindPlural groupVersionKind) { + super(GenericKubernetesResource.class, null); + this.groupVersionKind = groupVersionKind; + } + + public GenericKubernetesDependentResource(GroupVersionKindPlural groupVersionKind, String name) { + super(GenericKubernetesResource.class, name); + this.groupVersionKind = groupVersionKind; + } + + protected InformerEventSourceConfiguration.Builder + informerConfigurationBuilder(EventSourceContext

context) { + return InformerEventSourceConfiguration.from( + groupVersionKind, context.getPrimaryResourceClass()); + } + + @SuppressWarnings("unused") + public GroupVersionKindPlural getGroupVersionKind() { + return groupVersionKind; + } +} 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 new file mode 100644 index 0000000000..f96c28d60a --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java @@ -0,0 +1,201 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.zjsonpatch.JsonDiff; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.Matcher; + +import com.fasterxml.jackson.databind.JsonNode; + +public class GenericKubernetesResourceMatcher { + + private static final String SPEC = "/spec"; + private static final String METADATA = "/metadata"; + private static final String ADD = "add"; + private static final String OP = "op"; + private static final List IGNORED_FIELDS = List.of("/apiVersion", "/kind", "/status"); + public static final String METADATA_LABELS = "/metadata/labels"; + public static final String METADATA_ANNOTATIONS = "/metadata/annotations"; + + private static final String PATH = "path"; + private static final String[] EMPTY_ARRAY = {}; + + /** + * Determines whether the specified actual resource matches the specified desired resource, + * possibly considering metadata and deeper equality checks. + * + * @param desired the desired resource + * @param actualResource the actual resource + * @param labelsAndAnnotationsEquality if true labels and annotation match exactly in the actual + * and desired state if false, additional elements are allowed in actual annotations. + * Considered only if considerLabelsAndAnnotations is true. + * @param valuesEquality if {@code false}, the algorithm checks if the properties in the desired + * resource spec (or other non metadata value) are same as in the actual resource spec. The + * reason is that admission controllers and default Kubernetes controllers might add default + * values to some properties which are not set in the desired resources' spec and comparing it + * with simple equality check would mean that such resource will not match (while conceptually + * should). However, there is an issue with this for example if desired spec contains a list + * of values and a value is removed, this still will match the actual state from previous + * reconciliation. Setting this parameter to {@code true}, will match the resources only if + * all properties and values are equal. This could be implemented also by overriding equals + * method of spec, should be done as an optimization - this implementation does not require + * that. + * @param resource + * @return results of matching + */ + public static Matcher.Result match( + R desired, + R actualResource, + boolean labelsAndAnnotationsEquality, + boolean valuesEquality, + Context

context) { + return match( + desired, + actualResource, + labelsAndAnnotationsEquality, + valuesEquality, + context, + EMPTY_ARRAY); + } + + public static Matcher.Result match( + R desired, R actualResource, Context

context) { + return match(desired, actualResource, false, false, context, EMPTY_ARRAY); + } + + /** + * Determines whether the specified actual resource matches the specified desired resource, + * possibly considering metadata and deeper equality checks. + * + * @param desired the desired resource + * @param actualResource the actual resource + * @param labelsAndAnnotationsEquality if true labels and annotation match exactly in the actual + * and desired state if false, additional elements are allowed in actual annotations. + * Considered only if considerLabelsAndAnnotations is true. + * @param ignorePaths are paths in the resource that are ignored on matching (basically an ignore + * list). All changes with a target prefix path on a calculated JSON Patch between actual and + * desired will be ignored. If there are other changes, non-present on ignore list match + * fails. + * @param resource + * @return results of matching + */ + public static Matcher.Result match( + R desired, + R actualResource, + boolean labelsAndAnnotationsEquality, + Context

context, + String... ignorePaths) { + return match( + desired, actualResource, labelsAndAnnotationsEquality, false, context, ignorePaths); + } + + /** + * Determines whether the specified actual resource matches the desired state defined by the + * specified {@link KubernetesDependentResource} based on the observed state of the associated + * specified primary resource. + * + * @param dependentResource the {@link KubernetesDependentResource} implementation used to compute + * the desired state associated with the specified primary resource + * @param actualResource the observed dependent resource for which we want to determine whether it + * matches the desired state or not + * @param primary the primary resource from which we want to compute the desired state + * @param context the {@link Context} instance within which this method is called + * @param labelsAndAnnotationsEquality if true labels and annotation match exactly in the actual + * and desired state if false, additional elements are allowed in actual annotations. + * Considered only if considerLabelsAndAnnotations is true. + * @param the type of resource we want to determine whether they match or not + * @param

the type of primary resources associated with the secondary resources we want to + * match + * @param ignorePaths are paths in the resource that are ignored on matching (basically an ignore + * list). All changes with a target prefix path on a calculated JSON Patch between actual and + * desired will be ignored. If there are other changes, non-present on ignore list match + * fails. + * @return a {@link io.javaoperatorsdk.operator.processing.dependent.Matcher.Result} object + */ + public static Matcher.Result match( + KubernetesDependentResource dependentResource, + R actualResource, + P primary, + Context

context, + boolean labelsAndAnnotationsEquality, + String... ignorePaths) { + final var desired = dependentResource.desired(primary, context); + return match(desired, actualResource, labelsAndAnnotationsEquality, context, ignorePaths); + } + + public static Matcher.Result match( + KubernetesDependentResource dependentResource, + R actualResource, + P primary, + Context

context, + boolean specEquality, + boolean labelsAndAnnotationsEquality, + String... ignorePaths) { + final var desired = dependentResource.desired(primary, context); + return match( + desired, actualResource, labelsAndAnnotationsEquality, specEquality, context, ignorePaths); + } + + public static Matcher.Result match( + R desired, + R actualResource, + boolean labelsAndAnnotationsEquality, + boolean valuesEquality, + Context

context, + String... ignoredPaths) { + final List ignoreList = + ignoredPaths != null && ignoredPaths.length > 0 + ? Arrays.asList(ignoredPaths) + : Collections.emptyList(); + + if (valuesEquality && !ignoreList.isEmpty()) { + throw new IllegalArgumentException( + "Equality should be false in case of ignore list provided"); + } + + final var kubernetesSerialization = context.getClient().getKubernetesSerialization(); + var desiredNode = kubernetesSerialization.convertValue(desired, JsonNode.class); + var actualNode = kubernetesSerialization.convertValue(actualResource, JsonNode.class); + var wholeDiffJsonPatch = JsonDiff.asJson(desiredNode, actualNode); + + boolean matched = true; + for (int i = 0; i < wholeDiffJsonPatch.size() && matched; i++) { + var node = wholeDiffJsonPatch.get(i); + if (nodeIsChildOf(node, List.of(SPEC))) { + matched = match(valuesEquality, node, ignoreList); + } else if (nodeIsChildOf(node, List.of(METADATA))) { + // conditionally consider labels and annotations + if (nodeIsChildOf(node, List.of(METADATA_LABELS, METADATA_ANNOTATIONS))) { + matched = match(labelsAndAnnotationsEquality, node, Collections.emptyList()); + } + } else if (!nodeIsChildOf(node, IGNORED_FIELDS)) { + matched = match(valuesEquality, node, ignoreList); + } + } + + return Matcher.Result.computed(matched, desired); + } + + private static boolean match(boolean equality, JsonNode diff, final List ignoreList) { + if (equality) { + return false; + } + if (!ignoreList.isEmpty()) { + return nodeIsChildOf(diff, ignoreList); + } + return ADD.equals(diff.get(OP).asText()); + } + + static boolean nodeIsChildOf(JsonNode n, List prefixes) { + var path = getPath(n); + return prefixes.stream().anyMatch(path::startsWith); + } + + static String getPath(JsonNode n) { + return n.get(PATH).asText(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdater.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdater.java new file mode 100644 index 0000000000..c8123c39e5 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdater.java @@ -0,0 +1,35 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +public class GenericResourceUpdater { + + private static final String METADATA = "metadata"; + + @SuppressWarnings("unchecked") + public static R updateResource(R actual, R desired, Context context) { + KubernetesSerialization kubernetesSerialization = + context.getClient().getKubernetesSerialization(); + Map actualMap = kubernetesSerialization.convertValue(actual, Map.class); + Map desiredMap = kubernetesSerialization.convertValue(desired, Map.class); + // replace all top level fields from actual with desired, but merge metadata separately + // note that this ensures that `resourceVersion` is present, therefore optimistic + // locking will happen on server side + var metadata = actualMap.remove(METADATA); + actualMap.replaceAll((k, v) -> desiredMap.get(k)); + actualMap.putAll(desiredMap); + actualMap.put(METADATA, metadata); + var clonedActual = (R) kubernetesSerialization.convertValue(actualMap, desired.getClass()); + updateLabelsAndAnnotation(clonedActual, desired); + return clonedActual; + } + + public static void updateLabelsAndAnnotation(K actual, K desired) { + actual.getMetadata().getLabels().putAll(desired.getMetadata().getLabels()); + actual.getMetadata().getAnnotations().putAll(desired.getMetadata().getAnnotations()); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java new file mode 100644 index 0000000000..9771aba3dc --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java @@ -0,0 +1,135 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import java.util.Objects; +import java.util.Optional; + +import io.fabric8.kubernetes.api.Pluralize; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; + +/** + * An extension of {@link GroupVersionKind} that also records the associated plural form which is + * useful when dealing with Kubernetes RBACs. Downstream projects might leverage that information. + */ +public class GroupVersionKindPlural extends GroupVersionKind { + private final String plural; + + protected GroupVersionKindPlural(String group, String version, String kind, String plural) { + super(group, version, kind); + this.plural = plural; + } + + protected GroupVersionKindPlural(String apiVersion, String kind, String plural) { + super(apiVersion, kind); + this.plural = plural; + } + + protected GroupVersionKindPlural(GroupVersionKind gvk, String plural) { + this( + gvk.getGroup(), + gvk.getVersion(), + gvk.getKind(), + plural != null + ? plural + : (gvk instanceof GroupVersionKindPlural + ? ((GroupVersionKindPlural) gvk).plural + : null)); + } + + @Override + protected boolean specificEquals(GroupVersionKind that) { + if (plural == null) { + return true; + } + return that instanceof GroupVersionKindPlural gvkp && gvkp.plural.equals(plural); + } + + @Override + public int hashCode() { + return plural != null ? Objects.hash(super.hashCode(), plural) : super.hashCode(); + } + + @Override + public String toString() { + return toGVKString() + (plural != null ? " (plural: " + plural + ")" : ""); + } + + /** + * Creates a new GroupVersionKindPlural from the specified {@link GroupVersionKind}. + * + * @param gvk a {@link GroupVersionKind} from which to create a new GroupVersionKindPlural object + * @return a new GroupVersionKindPlural object matching the specified {@link GroupVersionKind} + */ + public static GroupVersionKindPlural from(GroupVersionKind gvk) { + return gvk instanceof GroupVersionKindPlural + ? ((GroupVersionKindPlural) gvk) + : gvkWithPlural(gvk, null); + } + + /** + * Creates a new GroupVersionKindPlural based on the specified {@link GroupVersionKind} instance + * but specifying a plural form to use as well. + * + * @param gvk the base {@link GroupVersionKind} from which to derive a new GroupVersionKindPlural + * @param plural the plural form to use for the new instance or {@code null} if the default plural + * form is desired. Note that the specified plural form will override any existing plural form + * for the specified {@link GroupVersionKind} (in particular, if the specified {@link + * GroupVersionKind} was already an instance of GroupVersionKindPlural, its plural form will + * only be considered in the new instance if the specified plural form is {@code null} + * @return a new GroupVersionKindPlural derived from the specified {@link GroupVersionKind} and + * plural form + */ + public static GroupVersionKindPlural gvkWithPlural(GroupVersionKind gvk, String plural) { + return new GroupVersionKindPlural(gvk, plural); + } + + /** + * Creates a new GroupVersionKindPlural instance extracting the information from the specified + * {@link HasMetadata} implementation + * + * @param resourceClass the {@link HasMetadata} from which group, version, kind and plural form + * are extracted + * @return a new GroupVersionKindPlural instance based on the specified {@link HasMetadata} + * implementation + */ + public static GroupVersionKindPlural gvkFor(Class resourceClass) { + final var gvk = GroupVersionKind.gvkFor(resourceClass); + return gvkWithPlural(gvk, HasMetadata.getPlural(resourceClass)); + } + + /** + * Retrieves the default plural form for the specified kind. + * + * @param kind the kind for which we want to get the default plural form + * @return the default plural form for the specified kind + */ + public static String getDefaultPluralFor(String kind) { + // todo: replace by Fabric8 version when available, see + // https://github.com/fabric8io/kubernetes-client/pull/6314 + return kind != null ? Pluralize.toPlural(kind.toLowerCase()) : null; + } + + /** + * Returns the plural form associated with the kind if it has been provided explicitly (either + * manually by the user, or determined from the associated resource class definition) + * + * @return {@link Optional#empty()} if the plural form was not provided explicitly, or the plural + * form if it was provided explicitly + */ + public Optional getPlural() { + return Optional.ofNullable(plural); + } + + /** + * Returns the plural form associated with the kind if it was provided or a default, computed form + * via {@link #getDefaultPluralFor(String)} (which should correspond to the actual plural form in + * most cases but might not always be correct, especially if the resource's creator defined an + * exotic plural form via the CRD. + * + * @return the plural form associated with the kind if provided or a default plural form otherwise + */ + @SuppressWarnings("unused") + public String getPluralOrDefault() { + return getPlural().orElse(getDefaultPluralFor(getKind())); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java new file mode 100644 index 0000000000..484ffb64c8 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java @@ -0,0 +1,50 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface KubernetesDependent { + + /** Creates the resource only if did not exist before, this applies only if SSA is used. */ + boolean createResourceOnlyIfNotExistingWithSSA() default + KubernetesDependentResourceConfig.DEFAULT_CREATE_RESOURCE_ONLY_IF_NOT_EXISTING_WITH_SSA; + + /** + * Determines whether to use SSA (Server-Side Apply) for this dependent. If SSA is used, the + * dependent resource will only be created if it did not exist before. Default value is {@link + * BooleanWithUndefined#UNDEFINED}, which specifies that the behavior with respect to SSA is + * inherited from the global configuration. + * + * @return {@code true} if SSA is enabled, {@code false} if SSA is disabled, {@link + * BooleanWithUndefined#UNDEFINED} if the SSA behavior should be inherited from the global + * configuration + */ + BooleanWithUndefined useSSA() default BooleanWithUndefined.UNDEFINED; + + /** + * The underlying Informer event source configuration + * + * @return the {@link Informer} configuration + */ + Informer informer() default @Informer; + + /** + * The specific matcher implementation to use when Server-Side Apply (SSA) is used, when case the + * default one isn't working appropriately. Typically, this could be needed to cover border cases + * with some Kubernetes resources that are modified by their controllers to normalize or add + * default values, which could result in infinite loops with the default matcher. Using a specific + * matcher could also be an optimization decision if determination of whether two resources match + * can be done faster than what can be done with the default exhaustive algorithm. + * + * @return the class of the specific matcher to use for the associated dependent resources + * @since 5.1 + */ + Class matcher() default + SSABasedGenericKubernetesResourceMatcher.class; +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverter.java new file mode 100644 index 0000000000..7d68b0e106 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverter.java @@ -0,0 +1,77 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.Utils; +import io.javaoperatorsdk.operator.api.config.dependent.ConfigurationConverter; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; + +import static io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig.DEFAULT_CREATE_RESOURCE_ONLY_IF_NOT_EXISTING_WITH_SSA; + +public class KubernetesDependentConverter + implements ConfigurationConverter> { + + @Override + @SuppressWarnings("unchecked") + public KubernetesDependentResourceConfig configFrom( + KubernetesDependent configAnnotation, + DependentResourceSpec> spec, + ControllerConfiguration controllerConfig) { + var createResourceOnlyIfNotExistingWithSSA = + DEFAULT_CREATE_RESOURCE_ONLY_IF_NOT_EXISTING_WITH_SSA; + + Boolean useSSA = null; + SSABasedGenericKubernetesResourceMatcher matcher = + SSABasedGenericKubernetesResourceMatcher.getInstance(); + if (configAnnotation != null) { + createResourceOnlyIfNotExistingWithSSA = + configAnnotation.createResourceOnlyIfNotExistingWithSSA(); + useSSA = configAnnotation.useSSA().asBoolean(); + + // check if we have a specific matcher + Class> dependentResourceClass = + (Class>) spec.getDependentResourceClass(); + final var context = + Utils.contextFor( + controllerConfig, dependentResourceClass, configAnnotation.annotationType()); + matcher = + Utils.instantiate( + configAnnotation.matcher(), SSABasedGenericKubernetesResourceMatcher.class, context); + } + + var informerConfiguration = + createInformerConfig( + configAnnotation, + (DependentResourceSpec>) spec, + controllerConfig); + + return new KubernetesDependentResourceConfig<>( + useSSA, createResourceOnlyIfNotExistingWithSSA, informerConfiguration, matcher); + } + + @SuppressWarnings({"unchecked"}) + private InformerConfiguration createInformerConfig( + KubernetesDependent configAnnotation, + DependentResourceSpec> spec, + ControllerConfiguration controllerConfig) { + Class> dependentResourceClass = + (Class>) spec.getDependentResourceClass(); + + final var resourceType = + controllerConfig + .getConfigurationService() + .dependentResourceFactory() + .associatedResourceType(spec); + + InformerConfiguration.Builder config = InformerConfiguration.builder(resourceType); + if (configAnnotation != null) { + final var informerConfig = configAnnotation.informer(); + final var context = + Utils.contextFor( + controllerConfig, dependentResourceClass, configAnnotation.annotationType()); + config = config.initFromAnnotation(informerConfig, context); + } + return config.build(); + } +} 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 new file mode 100644 index 0000000000..69d145866d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java @@ -0,0 +1,329 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.config.dependent.Configured; +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.api.reconciler.Ignore; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ConfiguredDependentResource; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; +import io.javaoperatorsdk.operator.processing.dependent.AbstractEventSourceHolderDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.Matcher.Result; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers; + +@Ignore +@Configured( + by = KubernetesDependent.class, + with = KubernetesDependentResourceConfig.class, + converter = KubernetesDependentConverter.class) +public abstract class KubernetesDependentResource + extends AbstractEventSourceHolderDependentResource> + implements ConfiguredDependentResource> { + + private static final Logger log = LoggerFactory.getLogger(KubernetesDependentResource.class); + + private final boolean garbageCollected = this instanceof GarbageCollected; + private KubernetesDependentResourceConfig kubernetesDependentResourceConfig; + private volatile Boolean useSSA; + private volatile Boolean usePreviousAnnotationForEventFiltering; + + public KubernetesDependentResource() {} + + public KubernetesDependentResource(Class resourceType) { + this(resourceType, null); + } + + public KubernetesDependentResource(Class resourceType, String name) { + super(resourceType, name); + } + + @Override + public void configureWith(KubernetesDependentResourceConfig config) { + this.kubernetesDependentResourceConfig = config; + } + + @SuppressWarnings("unused") + public R create(R desired, P primary, Context

context) { + if (useSSA(context)) { + // setting resource version for SSA so only created if it doesn't exist already + var createIfNotExisting = + kubernetesDependentResourceConfig == null + ? KubernetesDependentResourceConfig + .DEFAULT_CREATE_RESOURCE_ONLY_IF_NOT_EXISTING_WITH_SSA + : kubernetesDependentResourceConfig.createResourceOnlyIfNotExistingWithSSA(); + if (createIfNotExisting) { + desired.getMetadata().setResourceVersion("1"); + } + } + 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(); + } + + public R update(R actual, R desired, P primary, Context

context) { + boolean useSSA = useSSA(context); + if (log.isDebugEnabled()) { + log.debug( + "Updating actual resource: {} version: {}; SSA: {}", + ResourceID.fromResource(actual), + actual.getMetadata().getResourceVersion(), + useSSA); + } + R updatedResource; + addMetadata(false, actual, desired, primary, context); + if (useSSA) { + updatedResource = + prepare(context, desired, primary, "Updating") + .fieldManager(context.getControllerConfiguration().fieldManager()) + .forceConflicts() + .serverSideApply(); + } else { + var updatedActual = GenericResourceUpdater.updateResource(actual, desired, context); + updatedResource = prepare(context, updatedActual, primary, "Updating").update(); + } + log.debug( + "Resource version after update: {}", updatedResource.getMetadata().getResourceVersion()); + return updatedResource; + } + + @Override + public Result match(R actualResource, P primary, Context

context) { + final var desired = desired(primary, context); + return match(actualResource, desired, primary, context); + } + + public Result match(R actualResource, R desired, P primary, Context

context) { + final boolean matches; + addMetadata(true, actualResource, desired, primary, context); + if (useSSA(context)) { + matches = + configuration() + .map(KubernetesDependentResourceConfig::matcher) + .orElse(SSABasedGenericKubernetesResourceMatcher.getInstance()) + .matches(actualResource, desired, context); + } else { + matches = + GenericKubernetesResourceMatcher.match(desired, actualResource, false, false, context) + .matched(); + } + return Result.computed(matches, desired); + } + + protected void addMetadata( + boolean forMatch, R actualResource, final R target, P primary, Context

context) { + if (forMatch) { // keep the current previous annotation + String actual = + actualResource + .getMetadata() + .getAnnotations() + .get(InformerEventSource.PREVIOUS_ANNOTATION_KEY); + Map annotations = target.getMetadata().getAnnotations(); + if (actual != null) { + annotations.put(InformerEventSource.PREVIOUS_ANNOTATION_KEY, actual); + } 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); + } + + protected boolean useSSA(Context

context) { + if (useSSA == null) { + useSSA = + context + .getControllerConfiguration() + .getConfigurationService() + .shouldUseSSA(getClass(), resourceType(), configuration().orElse(null)); + } + 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) { + context.getClient().resource(secondary).delete(); + } + } + + @SuppressWarnings("unused") + public void deleteTargetResource(P primary, R resource, String key, Context

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()) { + ReconcilerUtils.checkIfCanAddOwnerReference(primary, desired); + desired.addOwnerReference(primary); + } else if (useNonOwnerRefBasedSecondaryToPrimaryMapping()) { + addSecondaryToPrimaryMapperAnnotations(desired, primary); + } + } + + @Override + protected InformerEventSource createEventSource(EventSourceContext

context) { + final InformerEventSourceConfiguration.Builder configBuilder = + informerConfigurationBuilder(context) + .withSecondaryToPrimaryMapper(getSecondaryToPrimaryMapper(context).orElseThrow()) + .withName(name()); + + // update configuration from annotation if specified + if (kubernetesDependentResourceConfig != null + && kubernetesDependentResourceConfig.informerConfig() != null) { + configBuilder.updateFrom(kubernetesDependentResourceConfig.informerConfig()); + } + + var es = new InformerEventSource<>(configBuilder.build(), context); + setEventSource(es); + return eventSource().orElseThrow(); + } + + /** + * To handle {@link io.fabric8.kubernetes.api.model.GenericKubernetesResource} based dependents. + */ + protected InformerEventSourceConfiguration.Builder informerConfigurationBuilder( + EventSourceContext

context) { + return InformerEventSourceConfiguration.from(resourceType(), context.getPrimaryResourceClass()); + } + + private boolean useNonOwnerRefBasedSecondaryToPrimaryMapping() { + return !garbageCollected && isCreatable(); + } + + protected void addSecondaryToPrimaryMapperAnnotations(R desired, P primary) { + addSecondaryToPrimaryMapperAnnotations( + desired, + primary, + Mappers.DEFAULT_ANNOTATION_FOR_NAME, + Mappers.DEFAULT_ANNOTATION_FOR_NAMESPACE, + Mappers.DEFAULT_ANNOTATION_FOR_PRIMARY_TYPE); + } + + protected void addSecondaryToPrimaryMapperAnnotations( + R desired, P primary, String nameKey, String namespaceKey, String typeKey) { + var annotations = desired.getMetadata().getAnnotations(); + annotations.put(nameKey, primary.getMetadata().getName()); + var primaryNamespaces = primary.getMetadata().getNamespace(); + if (primaryNamespaces != null) { + annotations.put(namespaceKey, primary.getMetadata().getNamespace()); + } + annotations.put(typeKey, GroupVersionKind.gvkFor(primary.getClass()).toGVKString()); + } + + @Override + protected Optional selectTargetSecondaryResource( + Set secondaryResources, P primary, Context

context) { + ResourceID managedResourceID = targetSecondaryResourceID(primary, context); + return secondaryResources.stream() + .filter( + r -> + r.getMetadata().getName().equals(managedResourceID.getName()) + && Objects.equals( + r.getMetadata().getNamespace(), + managedResourceID.getNamespace().orElse(null))) + .findFirst(); + } + + /** + * Override this method in order to optimize and not compute the desired when selecting the target + * secondary resource. Simply, a static ResourceID can be returned. + * + * @param primary resource + * @param context of current reconciliation + * @return id of the target managed resource + */ + protected ResourceID targetSecondaryResourceID(P primary, Context

context) { + return ResourceID.fromResource(desired(primary, context)); + } + + protected boolean addOwnerReference() { + return garbageCollected; + } + + @Override + protected R desired(P primary, Context

context) { + return super.desired(primary, context); + } + + @Override + public Optional> configuration() { + return Optional.ofNullable(kubernetesDependentResourceConfig); + } + + @Override + public boolean isDeletable() { + return super.isDeletable() && !garbageCollected; + } + + @SuppressWarnings("unchecked") + protected Optional> getSecondaryToPrimaryMapper( + EventSourceContext

context) { + if (this instanceof SecondaryToPrimaryMapper) { + return Optional.of((SecondaryToPrimaryMapper) this); + } else { + var clustered = !Namespaced.class.isAssignableFrom(context.getPrimaryResourceClass()); + if (garbageCollected) { + return Optional.of( + Mappers.fromOwnerReferences(context.getPrimaryResourceClass(), clustered)); + } else if (isCreatable()) { + return Optional.of(Mappers.fromDefaultAnnotations(context.getPrimaryResourceClass())); + } + } + return Optional.empty(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfig.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfig.java new file mode 100644 index 0000000000..6f626d2628 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfig.java @@ -0,0 +1,49 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; + +public class KubernetesDependentResourceConfig { + + public static final boolean DEFAULT_CREATE_RESOURCE_ONLY_IF_NOT_EXISTING_WITH_SSA = true; + + private final Boolean useSSA; + private final boolean createResourceOnlyIfNotExistingWithSSA; + private final InformerConfiguration informerConfig; + private final SSABasedGenericKubernetesResourceMatcher matcher; + + public KubernetesDependentResourceConfig( + Boolean useSSA, + boolean createResourceOnlyIfNotExistingWithSSA, + InformerConfiguration informerConfig) { + this(useSSA, createResourceOnlyIfNotExistingWithSSA, informerConfig, null); + } + + public KubernetesDependentResourceConfig( + Boolean useSSA, + boolean createResourceOnlyIfNotExistingWithSSA, + InformerConfiguration informerConfig, + SSABasedGenericKubernetesResourceMatcher matcher) { + this.useSSA = useSSA; + this.createResourceOnlyIfNotExistingWithSSA = createResourceOnlyIfNotExistingWithSSA; + this.informerConfig = informerConfig; + this.matcher = + matcher != null ? matcher : SSABasedGenericKubernetesResourceMatcher.getInstance(); + } + + public boolean createResourceOnlyIfNotExistingWithSSA() { + return createResourceOnlyIfNotExistingWithSSA; + } + + public Boolean useSSA() { + return useSSA; + } + + public InformerConfiguration informerConfig() { + return informerConfig; + } + + public SSABasedGenericKubernetesResourceMatcher matcher() { + return matcher; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfigBuilder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfigBuilder.java new file mode 100644 index 0000000000..371fb700c3 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfigBuilder.java @@ -0,0 +1,43 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; + +public final class KubernetesDependentResourceConfigBuilder { + + private boolean createResourceOnlyIfNotExistingWithSSA; + private Boolean useSSA = null; + private InformerConfiguration informerConfiguration; + private SSABasedGenericKubernetesResourceMatcher matcher; + + public KubernetesDependentResourceConfigBuilder() {} + + @SuppressWarnings("unused") + public KubernetesDependentResourceConfigBuilder withCreateResourceOnlyIfNotExistingWithSSA( + boolean createResourceOnlyIfNotExistingWithSSA) { + this.createResourceOnlyIfNotExistingWithSSA = createResourceOnlyIfNotExistingWithSSA; + return this; + } + + public KubernetesDependentResourceConfigBuilder withUseSSA(boolean useSSA) { + this.useSSA = useSSA; + return this; + } + + public KubernetesDependentResourceConfigBuilder withKubernetesDependentInformerConfig( + InformerConfiguration informerConfiguration) { + this.informerConfiguration = informerConfiguration; + return this; + } + + public KubernetesDependentResourceConfigBuilder withSSAMatcher( + SSABasedGenericKubernetesResourceMatcher matcher) { + this.matcher = matcher; + return this; + } + + public KubernetesDependentResourceConfig build() { + return new KubernetesDependentResourceConfig<>( + useSSA, createResourceOnlyIfNotExistingWithSSA, informerConfiguration, matcher); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/PodTemplateSpecSanitizer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/PodTemplateSpecSanitizer.java new file mode 100644 index 0000000000..fd1dcff49c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/PodTemplateSpecSanitizer.java @@ -0,0 +1,197 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.Container; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.PodTemplateSpec; +import io.fabric8.kubernetes.api.model.Quantity; +import io.fabric8.kubernetes.api.model.ResourceRequirements; + +/** + * Sanitizes the {@link ResourceRequirements} and the {@link EnvVar} in the containers of a pair of + * {@link PodTemplateSpec} instances. + * + *

When the sanitizer finds a mismatch in the structure of the given templates, before it gets to + * the nested fields, it returns early without fixing the actual map. This is an optimization + * because the given templates will anyway differ at this point. This means we do not have to + * attempt to sanitize the fields for these use cases, since there will anyway be an update of the + * K8s resource. + * + *

The algorithm traverses the whole template structure because we need the actual and desired + * {@link Quantity} and {@link EnvVar} instances. Using the {@link + * GenericKubernetesResource#get(Map, Object...)} shortcut would need to create new instances just + * for the sanitization check. + */ +class PodTemplateSpecSanitizer { + + static void sanitizePodTemplateSpec( + final Map actualMap, + final PodTemplateSpec actualTemplate, + final PodTemplateSpec desiredTemplate) { + if (actualTemplate == null || desiredTemplate == null) { + return; + } + if (actualTemplate.getSpec() == null || desiredTemplate.getSpec() == null) { + return; + } + sanitizePodTemplateSpec( + actualMap, + actualTemplate.getSpec().getInitContainers(), + desiredTemplate.getSpec().getInitContainers(), + "initContainers"); + sanitizePodTemplateSpec( + actualMap, + actualTemplate.getSpec().getContainers(), + desiredTemplate.getSpec().getContainers(), + "containers"); + } + + private static void sanitizePodTemplateSpec( + final Map actualMap, + final List actualContainers, + final List desiredContainers, + final String containerPath) { + int containers = desiredContainers.size(); + if (containers == actualContainers.size()) { + for (int containerIndex = 0; containerIndex < containers; containerIndex++) { + final var desiredContainer = desiredContainers.get(containerIndex); + final var actualContainer = actualContainers.get(containerIndex); + if (!desiredContainer.getName().equals(actualContainer.getName())) { + return; + } + sanitizeEnvVars( + actualMap, + actualContainer.getEnv(), + desiredContainer.getEnv(), + containerPath, + containerIndex); + sanitizeResourceRequirements( + actualMap, + actualContainer.getResources(), + desiredContainer.getResources(), + containerPath, + containerIndex); + } + } + } + + private static void sanitizeResourceRequirements( + final Map actualMap, + final ResourceRequirements actualResource, + final ResourceRequirements desiredResource, + final String containerPath, + final int containerIndex) { + if (desiredResource == null || actualResource == null) { + return; + } + sanitizeQuantities( + actualMap, + actualResource.getRequests(), + desiredResource.getRequests(), + containerPath, + containerIndex, + "requests"); + sanitizeQuantities( + actualMap, + actualResource.getLimits(), + desiredResource.getLimits(), + containerPath, + containerIndex, + "limits"); + } + + @SuppressWarnings("unchecked") + private static void sanitizeQuantities( + final Map actualMap, + final Map actualResource, + final Map desiredResource, + final String containerPath, + final int containerIndex, + final String quantityPath) { + Optional.ofNullable( + GenericKubernetesResource.get( + actualMap, + "spec", + "template", + "spec", + containerPath, + containerIndex, + "resources", + quantityPath)) + .map(Map.class::cast) + .ifPresent( + m -> + actualResource.forEach( + (key, actualQuantity) -> { + final var desiredQuantity = desiredResource.get(key); + if (desiredQuantity == null) { + return; + } + // check if the string representation of the Quantity instances is equal + if (actualQuantity.getAmount().equals(desiredQuantity.getAmount()) + && actualQuantity.getFormat().equals(desiredQuantity.getFormat())) { + return; + } + // check if the numerical amount of the Quantity instances is equal + if (actualQuantity.equals(desiredQuantity)) { + // replace the actual Quantity with the desired Quantity to prevent a + // resource update + m.replace(key, desiredQuantity.toString()); + } + })); + } + + @SuppressWarnings("unchecked") + private static void sanitizeEnvVars( + final Map actualMap, + final List actualEnvVars, + final List desiredEnvVars, + final String containerPath, + final int containerIndex) { + if (desiredEnvVars.isEmpty() || actualEnvVars.isEmpty()) { + return; + } + Optional.ofNullable( + GenericKubernetesResource.get( + actualMap, "spec", "template", "spec", containerPath, containerIndex, "env")) + .map(List.class::cast) + .ifPresent( + envVars -> + actualEnvVars.forEach( + actualEnvVar -> { + final var actualEnvVarName = actualEnvVar.getName(); + final var actualEnvVarValue = actualEnvVar.getValue(); + // check if the actual EnvVar value string is not null or the desired EnvVar + // already contains the same EnvVar name with a non empty EnvVar value + final var isDesiredEnvVarEmpty = + hasEnvVarNoEmptyValue(actualEnvVarName, desiredEnvVars); + if (actualEnvVarValue != null || isDesiredEnvVarEmpty) { + return; + } + envVars.stream() + .filter( + envVar -> + ((Map) envVar) + .get("name") + .equals(actualEnvVarName)) + // add the actual EnvVar value with an empty string to prevent a + // resource update + .forEach(envVar -> ((Map) envVar).put("value", "")); + })); + } + + private static boolean hasEnvVarNoEmptyValue( + final String envVarName, final List envVars) { + return envVars.stream() + .anyMatch( + envVar -> + Objects.equals(envVarName, envVar.getName()) + && envVar.getValue() != null + && !envVar.getValue().isEmpty()); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/ResourceComparators.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/ResourceComparators.java new file mode 100644 index 0000000000..eeb22353d2 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/ResourceComparators.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import java.util.Objects; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.Secret; + +public class ResourceComparators { + + public static boolean compareConfigMapData(ConfigMap c1, ConfigMap c2) { + return Objects.equals(c1.getData(), c2.getData()) + && Objects.equals(c1.getBinaryData(), c2.getBinaryData()); + } + + public static boolean compareSecretData(Secret s1, Secret s2) { + return Objects.equals(s1.getType(), s2.getType()) + && Objects.equals(s1.getData(), s2.getData()) + && Objects.equals(s1.getStringData(), s2.getStringData()); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java new file mode 100644 index 0000000000..4954dfd17a --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java @@ -0,0 +1,497 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.ManagedFieldsEntry; +import io.fabric8.kubernetes.api.model.apps.DaemonSet; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.ReplicaSet; +import io.fabric8.kubernetes.api.model.apps.StatefulSet; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.LoggingUtils; + +import com.github.difflib.DiffUtils; +import com.github.difflib.UnifiedDiffUtils; + +import static io.javaoperatorsdk.operator.processing.dependent.kubernetes.PodTemplateSpecSanitizer.sanitizePodTemplateSpec; + +/** + * Matches the actual state on the server vs the desired state. Based on the managedFields of SSA. + * + *

The basis of the algorithm is to extract the managed fields by converting resources to a + * Map/List composition. The actual resource (from the server) is pruned, all the fields which are + * not mentioned in managedFields of the target manager are removed. Some irrelevant fields are also + * removed from the desired resource. Finally, the two resulting maps are compared for equality. + * + *

The implementation is a bit nasty since we have to deal with some specific cases of + * managedFields formats. + * + * @param matched resource type + */ +// https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#fieldsv1-v1-meta +// https://github.com/kubernetes-sigs/structured-merge-diff +// https://docs.aws.amazon.com/eks/latest/userguide/kubernetes-field-management.html +// see also: https://kubernetes.slack.com/archives/C0123CNN8F3/p1686141087220719 +public class SSABasedGenericKubernetesResourceMatcher { + + public static final String APPLY_OPERATION = "Apply"; + public static final String DOT_KEY = "."; + + private static final String F_PREFIX = "f:"; + private static final String K_PREFIX = "k:"; + private static final String V_PREFIX = "v:"; + private static final String METADATA_KEY = "metadata"; + private static final String NAME_KEY = "name"; + private static final String NAMESPACE_KEY = "namespace"; + private static final String KIND_KEY = "kind"; + private static final String API_VERSION_KEY = "apiVersion"; + + @SuppressWarnings("rawtypes") + private static final SSABasedGenericKubernetesResourceMatcher INSTANCE = + new SSABasedGenericKubernetesResourceMatcher<>(); + + private static final List IGNORED_METADATA = + List.of("creationTimestamp", "deletionTimestamp", "generation", "selfLink", "uid"); + + private static final Logger log = + LoggerFactory.getLogger(SSABasedGenericKubernetesResourceMatcher.class); + + @SuppressWarnings("unchecked") + public static SSABasedGenericKubernetesResourceMatcher getInstance() { + return INSTANCE; + } + + @SuppressWarnings("unchecked") + public boolean matches(R actual, R desired, Context context) { + var optionalManagedFieldsEntry = + checkIfFieldManagerExists(actual, context.getControllerConfiguration().fieldManager()); + // If no field is managed by our controller, that means the controller hasn't touched the + // resource yet and the resource probably doesn't match the desired state. Not matching here + // means that the resource will need to be updated and since this will be done using SSA, the + // fields our controller cares about will become managed by it + if (optionalManagedFieldsEntry.isEmpty()) { + return false; + } + + var managedFieldsEntry = optionalManagedFieldsEntry.orElseThrow(); + + var objectMapper = context.getClient().getKubernetesSerialization(); + var actualMap = objectMapper.convertValue(actual, Map.class); + var desiredMap = objectMapper.convertValue(desired, Map.class); + if (LoggingUtils.isNotSensitiveResource(desired)) { + log.trace("Original actual:\n {}\n original desired:\n {}", actualMap, desiredMap); + } + + sanitizeState(actual, desired, actualMap); + var prunedActual = new HashMap(actualMap.size()); + keepOnlyManagedFields( + prunedActual, + actualMap, + managedFieldsEntry.getFieldsV1().getAdditionalProperties(), + objectMapper); + + removeIrrelevantValues(desiredMap); + + var matches = matches(prunedActual, desiredMap, actual, desired, context); + if (!matches && log.isDebugEnabled() && LoggingUtils.isNotSensitiveResource(desired)) { + var diff = getDiff(prunedActual, desiredMap, objectMapper); + log.debug( + "Diff between actual and desired state for resource: {} with name: {} in namespace: {}" + + " is:\n" + + "{}", + actual.getKind(), + actual.getMetadata().getName(), + actual.getMetadata().getNamespace(), + diff); + } + return matches; + } + + /** + * Compares the desired and actual resources for equality. + * + *

This method can be overridden to implement custom matching logic. The {@code actualMap} is a + * cleaned-up version of the actual resource with managed fields and irrelevant values removed. + * + * @param actualMap the actual resource represented as a map + * @param desiredMap the desired resource represented as a map + * @param actual the actual resource object + * @param desired the desired resource object + * @param context the current matching context + * @return {@code true} if the resources are equal, otherwise {@code false} + */ + protected boolean matches( + Map actualMap, + Map desiredMap, + R actual, + R desired, + Context context) { + return actualMap.equals(desiredMap); + } + + private Optional checkIfFieldManagerExists(R actual, String fieldManager) { + var targetManagedFields = + actual.getMetadata().getManagedFields().stream() + // Only the apply operations are interesting for us since those were created properly be + // SSA patch. An update can be present with same fieldManager when migrating and having + // the same field manager name + .filter( + f -> + f.getManager().equals(fieldManager) && f.getOperation().equals(APPLY_OPERATION)) + .toList(); + if (targetManagedFields.isEmpty()) { + log.debug( + "No field manager exists for resource: {} with name: {} and operation {}", + actual.getKind(), + actual.getMetadata().getName(), + APPLY_OPERATION); + return Optional.empty(); + } + // this should not happen in theory + if (targetManagedFields.size() > 1) { + throw new OperatorException( + "More than one field manager exists with name: " + + fieldManager + + " in resource: " + + actual.getKind() + + " with name: " + + actual.getMetadata().getName()); + } + return Optional.of(targetManagedFields.get(0)); + } + + /** Correct for known issue with SSA */ + protected void sanitizeState(R actual, R desired, Map actualMap) { + if (actual instanceof StatefulSet actualStatefulSet + && desired instanceof StatefulSet desiredStatefulSet) { + var actualSpec = actualStatefulSet.getSpec(); + var desiredSpec = desiredStatefulSet.getSpec(); + int claims = desiredSpec.getVolumeClaimTemplates().size(); + if (claims == actualSpec.getVolumeClaimTemplates().size()) { + for (int i = 0; i < claims; i++) { + var claim = desiredSpec.getVolumeClaimTemplates().get(i); + if (claim.getSpec().getVolumeMode() == null) { + Optional.ofNullable( + GenericKubernetesResource.get( + actualMap, "spec", "volumeClaimTemplates", i, "spec")) + .map(Map.class::cast) + .ifPresent(m -> m.remove("volumeMode")); + } + if (claim.getStatus() == null) { + Optional.ofNullable( + GenericKubernetesResource.get(actualMap, "spec", "volumeClaimTemplates", i)) + .map(Map.class::cast) + .ifPresent(m -> m.remove("status")); + } + } + } + sanitizePodTemplateSpec(actualMap, actualSpec.getTemplate(), desiredSpec.getTemplate()); + } else if (actual instanceof Deployment actualDeployment + && desired instanceof Deployment desiredDeployment) { + sanitizePodTemplateSpec( + actualMap, + actualDeployment.getSpec().getTemplate(), + desiredDeployment.getSpec().getTemplate()); + } else if (actual instanceof ReplicaSet actualReplicaSet + && desired instanceof ReplicaSet desiredReplicaSet) { + sanitizePodTemplateSpec( + actualMap, + actualReplicaSet.getSpec().getTemplate(), + desiredReplicaSet.getSpec().getTemplate()); + } else if (actual instanceof DaemonSet actualDaemonSet + && desired instanceof DaemonSet desiredDaemonSet) { + sanitizePodTemplateSpec( + actualMap, + actualDaemonSet.getSpec().getTemplate(), + desiredDaemonSet.getSpec().getTemplate()); + } + } + + @SuppressWarnings("unchecked") + static void keepOnlyManagedFields( + Map result, + Map actualMap, + Map managedFields, + KubernetesSerialization objectMapper) { + if (managedFields.isEmpty()) { + result.putAll(actualMap); + return; + } + for (var entry : managedFields.entrySet()) { + var key = entry.getKey(); + if (key.startsWith(F_PREFIX)) { + var keyInActual = keyWithoutPrefix(key); + var managedFieldValue = (Map) entry.getValue(); + if (isNestedValue(managedFieldValue)) { + var managedEntrySet = managedFieldValue.entrySet(); + // two special cases "k:" and "v:" prefixes + if (isListKeyEntrySet(managedEntrySet)) { + handleListKeyEntrySet(result, actualMap, objectMapper, keyInActual, managedEntrySet); + } else if (isSetValueField(managedEntrySet)) { + handleSetValues(result, actualMap, objectMapper, keyInActual, managedEntrySet); + } else { + // basically if we should traverse further + fillResultsAndTraverseFurther( + result, + actualMap, + managedFields, + objectMapper, + key, + keyInActual, + managedFieldValue); + } + } else { + // this should handle the case when the value is complex in the actual map (not just a + // simple value) + result.put(keyInActual, actualMap.get(keyInActual)); + } + } else { + // .:{} is ignored, other should not be present + if (!DOT_KEY.equals(key)) { + throw new IllegalStateException("Key: " + key + " has no prefix: " + F_PREFIX); + } + } + } + } + + private static boolean isNestedValue(Map managedFieldValue) { + return !managedFieldValue.isEmpty(); + } + + private static boolean isListKeyEntrySet(Set> managedEntrySet) { + return isKeyPrefixedSkippingDotKey(managedEntrySet, K_PREFIX); + } + + private static boolean isSetValueField(Set> managedEntrySet) { + return isKeyPrefixedSkippingDotKey(managedEntrySet, V_PREFIX); + } + + /** + * Sometimes (not always) the first subfield of a managed field ("f:") is ".:{}", it looks that + * those are added when there are more subfields of a referenced field. See test samples. Does not + * seem to provide additional functionality, so can be just skipped for now. + */ + private static boolean isKeyPrefixedSkippingDotKey( + Set> managedEntrySet, String prefix) { + var iterator = managedEntrySet.iterator(); + var managedFieldEntry = iterator.next(); + if (managedFieldEntry.getKey().equals(DOT_KEY)) { + managedFieldEntry = iterator.next(); + } + return managedFieldEntry.getKey().startsWith(prefix); + } + + /** + * List entries referenced by key, or when "k:" prefix is used. It works in a way that it selects + * the target element based on the field(s) in "k:" for example when there is a list of element of + * owner references, the uid can serve as a key for a list element: + * "k:{"uid":"1ef74cb4-dbbd-45ef-9caf-aa76186594ea"}". It selects the element and recursively + * processes it. Note that in these lists the order matters and seems that if there are more keys + * ("k:"), the ordering of those in the managed fields are not the same as the value order. So + * this also explicitly orders the result based on the value order in the resource not the key + * order in managed field. + */ + @SuppressWarnings("unchecked") + private static void handleListKeyEntrySet( + Map result, + Map actualMap, + KubernetesSerialization objectMapper, + String keyInActual, + Set> managedEntrySet) { + var valueList = new ArrayList<>(); + result.put(keyInActual, valueList); + var actualValueList = (List>) actualMap.get(keyInActual); + + var targetValuesByIndex = new TreeMap>(); + var managedEntryByIndex = new HashMap>(); + + for (var listEntry : managedEntrySet) { + if (DOT_KEY.equals(listEntry.getKey())) { + continue; + } + var actualListEntry = + selectListEntryBasedOnKey( + keyWithoutPrefix(listEntry.getKey()), actualValueList, objectMapper); + targetValuesByIndex.put(actualListEntry.getKey(), actualListEntry.getValue()); + managedEntryByIndex.put(actualListEntry.getKey(), (Map) listEntry.getValue()); + } + + targetValuesByIndex.forEach( + (key, value) -> { + var emptyResMapValue = new HashMap(); + valueList.add(emptyResMapValue); + keepOnlyManagedFields( + emptyResMapValue, value, managedEntryByIndex.get(key), objectMapper); + }); + } + + @SuppressWarnings("unchecked") + private static Map.Entry> selectListEntryBasedOnKey( + String key, List> values, KubernetesSerialization objectMapper) { + Map ids = objectMapper.unmarshal(key, Map.class); + var possibleTargets = new ArrayList>(1); + int lastIndex = -1; + for (int i = 0; i < values.size(); i++) { + var value = values.get(i); + if (value.entrySet().containsAll(ids.entrySet())) { + possibleTargets.add(value); + lastIndex = i; + } + } + if (possibleTargets.isEmpty()) { + throw new IllegalStateException( + "Cannot find list element for key: " + + key + + " in map: " + + values.stream().map(Map::keySet).toList()); + } + if (possibleTargets.size() > 1) { + throw new IllegalStateException( + "More targets found in list element for key: " + + key + + " in map: " + + values.stream().map(Map::keySet).toList()); + } + return new AbstractMap.SimpleEntry<>(lastIndex, possibleTargets.get(0)); + } + + /** + * Set values, the {@code "v:"} prefix. Form in managed fields: {@code + * "f:some-set":{"v:1":{}},"v:2":{},"v:3":{}}. + * + *

Note that this should be just used in very rare cases, actually was not able to produce a + * sample. Kubernetes developers who worked on this feature were not able to provide one either + * when prompted. Basically this method just adds the values from {@code "v:"} to the + * result. + */ + private static void handleSetValues( + Map result, + Map actualMap, + KubernetesSerialization objectMapper, + String keyInActual, + Set> managedEntrySet) { + var valueList = new ArrayList<>(); + result.put(keyInActual, valueList); + for (var valueEntry : managedEntrySet) { + // not clear if this can happen + if (DOT_KEY.equals(valueEntry.getKey())) { + continue; + } + var values = (List) actualMap.get(keyInActual); + var targetClass = (values.get(0) instanceof Map) ? null : values.get(0).getClass(); + var value = parseKeyValue(keyWithoutPrefix(valueEntry.getKey()), targetClass, objectMapper); + valueList.add(value); + } + } + + public static Object parseKeyValue( + String stringValue, Class targetClass, KubernetesSerialization objectMapper) { + var type = Objects.requireNonNullElse(targetClass, Map.class); + return objectMapper.unmarshal(stringValue.trim(), type); + } + + @SuppressWarnings("unchecked") + private static void fillResultsAndTraverseFurther( + Map result, + Map actualMap, + Map managedFields, + KubernetesSerialization objectMapper, + String key, + String keyInActual, + Object managedFieldValue) { + var emptyMapValue = new HashMap(); + result.put(keyInActual, emptyMapValue); + var actualMapValue = actualMap.getOrDefault(keyInActual, Collections.emptyMap()); + log.debug("key: {} actual map value: managedFieldValue: {}", keyInActual, managedFieldValue); + keepOnlyManagedFields( + emptyMapValue, + (Map) actualMapValue, + (Map) managedFields.get(key), + objectMapper); + } + + @SuppressWarnings("unchecked") + private static void removeIrrelevantValues(Map desiredMap) { + var metadata = (Map) desiredMap.get(METADATA_KEY); + metadata.remove(NAME_KEY); + metadata.remove(NAMESPACE_KEY); + IGNORED_METADATA.forEach(metadata::remove); + if (metadata.isEmpty()) { + desiredMap.remove(METADATA_KEY); + } + desiredMap.remove(KIND_KEY); + desiredMap.remove(API_VERSION_KEY); + } + + private static String getDiff( + Map prunedActualMap, + Map desiredMap, + KubernetesSerialization serialization) { + var actualYaml = serialization.asYaml(sortMap(prunedActualMap)); + var desiredYaml = serialization.asYaml(sortMap(desiredMap)); + if (log.isTraceEnabled()) { + log.trace("Pruned actual resource:\n {} \ndesired resource:\n {} ", actualYaml, desiredYaml); + } + + var patch = DiffUtils.diff(actualYaml.lines().toList(), desiredYaml.lines().toList()); + var unifiedDiff = + UnifiedDiffUtils.generateUnifiedDiff("", "", actualYaml.lines().toList(), patch, 1); + return String.join("\n", unifiedDiff); + } + + @SuppressWarnings("unchecked") + static Map sortMap(Map map) { + var sortedKeys = new ArrayList<>(map.keySet()); + Collections.sort(sortedKeys); + + var sortedMap = new LinkedHashMap(); + for (var key : sortedKeys) { + var value = map.get(key); + if (value instanceof Map) { + sortedMap.put(key, sortMap((Map) value)); + } else if (value instanceof List) { + sortedMap.put(key, sortListItems((List) value)); + } else { + sortedMap.put(key, value); + } + } + return sortedMap; + } + + @SuppressWarnings("unchecked") + static List sortListItems(List list) { + var sortedList = new ArrayList<>(); + for (var item : list) { + if (item instanceof Map) { + sortedList.add(sortMap((Map) item)); + } else if (item instanceof List) { + sortedList.add(sortListItems((List) item)); + } else { + sortedList.add(item); + } + } + return sortedList; + } + + private static String keyWithoutPrefix(String key) { + return key.substring(2); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutor.java new file mode 100644 index 0000000000..447f89ab30 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutor.java @@ -0,0 +1,189 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.slf4j.Logger; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +@SuppressWarnings("rawtypes") +abstract class AbstractWorkflowExecutor

{ + + protected final DefaultWorkflow

workflow; + protected final P primary; + protected final ResourceID primaryID; + protected final Context

context; + protected final Map, BaseWorkflowResult.DetailBuilder> results; + + /** Covers both deleted and reconciled */ + private final Map> actualExecutions = new ConcurrentHashMap<>(); + + private final ExecutorService executorService; + + protected AbstractWorkflowExecutor(DefaultWorkflow

workflow, P primary, Context

context) { + this.workflow = workflow; + this.primary = primary; + this.context = context; + this.primaryID = ResourceID.fromResource(primary); + executorService = context.getWorkflowExecutorService(); + results = new HashMap<>(workflow.getDependentResourcesByName().size()); + } + + protected abstract Logger logger(); + + protected synchronized void waitForScheduledExecutionsToRun() { + // in case when workflow just contains non-activated dependents, + // it needs to be checked first if there are already no executions + // scheduled at the beginning. + if (noMoreExecutionsScheduled()) { + return; + } + while (true) { + try { + this.wait(); + if (noMoreExecutionsScheduled()) { + break; + } else { + logger().warn("Notified but still resources under execution. This should not happen."); + } + } catch (InterruptedException e) { + if (noMoreExecutionsScheduled()) { + logger().debug("interrupted, no more executions for: {}", primaryID); + return; + } else { + logger().error("Thread interrupted for primary: {}", primaryID, e); + throw new OperatorException(e); + } + } + } + } + + protected boolean noMoreExecutionsScheduled() { + return actualExecutions.isEmpty(); + } + + protected boolean alreadyVisited(DependentResourceNode dependentResourceNode) { + return getResultFlagFor(dependentResourceNode, BaseWorkflowResult.DetailBuilder::isVisited); + } + + protected boolean postDeleteConditionNotMet(DependentResourceNode drn) { + return getResultFlagFor(drn, BaseWorkflowResult.DetailBuilder::hasPostDeleteConditionNotMet); + } + + protected boolean isMarkedForDelete(DependentResourceNode drn) { + return getResultFlagFor(drn, BaseWorkflowResult.DetailBuilder::isMarkedForDelete); + } + + protected synchronized BaseWorkflowResult.DetailBuilder createOrGetResultFor( + DependentResourceNode dependentResourceNode) { + return results.computeIfAbsent( + dependentResourceNode, unused -> new BaseWorkflowResult.DetailBuilder()); + } + + protected synchronized Optional> getResultFor( + DependentResourceNode dependentResourceNode) { + return Optional.ofNullable(results.get(dependentResourceNode)); + } + + protected boolean getResultFlagFor( + DependentResourceNode dependentResourceNode, + Function, Boolean> flag) { + return getResultFor(dependentResourceNode).map(flag).orElse(false); + } + + protected boolean isExecutingNow(DependentResourceNode dependentResourceNode) { + return actualExecutions.containsKey(dependentResourceNode); + } + + protected void markAsExecuting( + DependentResourceNode dependentResourceNode, Future future) { + actualExecutions.put(dependentResourceNode, future); + } + + // Exception is required because of Kotlin + protected synchronized void handleExceptionInExecutor( + DependentResourceNode dependentResourceNode, Exception e) { + createOrGetResultFor(dependentResourceNode).withError(e); + } + + protected boolean isReady(DependentResourceNode dependentResourceNode) { + return getResultFlagFor(dependentResourceNode, BaseWorkflowResult.DetailBuilder::isReady); + } + + protected boolean isInError(DependentResourceNode dependentResourceNode) { + return getResultFlagFor(dependentResourceNode, BaseWorkflowResult.DetailBuilder::hasError); + } + + protected synchronized void handleNodeExecutionFinish( + DependentResourceNode dependentResourceNode) { + logger().trace("Finished execution for: {} primary: {}", dependentResourceNode, primaryID); + actualExecutions.remove(dependentResourceNode); + if (noMoreExecutionsScheduled()) { + this.notifyAll(); + } + } + + @SuppressWarnings({"unchecked", "OptionalUsedAsFieldOrParameterType"}) + protected boolean isConditionMet( + Optional> condition, + DependentResourceNode dependentResource) { + final var dr = dependentResource.getDependentResource(); + return condition + .map( + c -> { + final DetailedCondition.Result r = c.detailedIsMet(dr, primary, context); + synchronized (this) { + results + .computeIfAbsent( + dependentResource, unused -> new BaseWorkflowResult.DetailBuilder()) + .withResultForCondition(c, r); + } + return r; + }) + .orElse(DetailedCondition.Result.metWithoutResult) + .isSuccess(); + } + + protected void submit( + DependentResourceNode dependentResourceNode, + NodeExecutor nodeExecutor, + String operation) { + final Future future = executorService.submit(nodeExecutor); + markAsExecuting(dependentResourceNode, future); + logger() + .debug("Submitted to {}: {} primaryID: {}", operation, dependentResourceNode, primaryID); + } + + protected void registerOrDeregisterEventSourceBasedOnActivation( + boolean activationConditionMet, DependentResourceNode dependentResourceNode) { + if (dependentResourceNode.getActivationCondition().isPresent()) { + final var dr = dependentResourceNode.getDependentResource(); + final var eventSourceRetriever = context.eventSourceRetriever(); + var eventSource = + dr.eventSource(eventSourceRetriever.eventSourceContextForDynamicRegistration()); + if (activationConditionMet) { + var es = eventSource.orElseThrow(); + eventSourceRetriever.dynamicallyRegisterEventSource(es); + } else { + eventSourceRetriever.dynamicallyDeRegisterEventSource(eventSource.orElseThrow().name()); + } + } + } + + protected synchronized Map> asDetails() { + return results.entrySet().stream() + .collect( + Collectors.toMap(e -> e.getKey().getDependentResource(), e -> e.getValue().build())); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/BaseWorkflowResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/BaseWorkflowResult.java new file mode 100644 index 0000000000..f751c88f97 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/BaseWorkflowResult.java @@ -0,0 +1,210 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import io.javaoperatorsdk.operator.AggregatedOperatorException; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; + +@SuppressWarnings("rawtypes") +class BaseWorkflowResult implements WorkflowResult { + private final Map> results; + private Boolean hasErroredDependents; + + BaseWorkflowResult(Map> results) { + this.results = results; + } + + @Override + public Map getErroredDependents() { + return getErroredDependentsStream() + .collect(Collectors.toMap(Entry::getKey, entry -> entry.getValue().error)); + } + + private Stream>> getErroredDependentsStream() { + return results.entrySet().stream().filter(entry -> entry.getValue().error != null); + } + + protected Map> results() { + return results; + } + + @Override + public Optional getDependentResourceByName(String name) { + if (name == null || name.isEmpty()) { + return Optional.empty(); + } + return results.keySet().stream().filter(dr -> dr.name().equals(name)).findFirst(); + } + + @Override + public Optional getDependentConditionResult( + DependentResource dependentResource, + Condition.Type conditionType, + Class expectedResultType) { + if (dependentResource == null) { + return Optional.empty(); + } + + final var result = new Object[1]; + try { + return Optional.ofNullable(results().get(dependentResource)) + .flatMap(detail -> detail.getResultForConditionWithType(conditionType)) + .map(r -> result[0] = r.getDetail()) + .map(expectedResultType::cast); + } catch (Exception e) { + throw new IllegalArgumentException( + "Condition " + + "result " + + result[0] + + " for Dependent " + + dependentResource.name() + + " doesn't match expected type " + + expectedResultType.getSimpleName(), + e); + } + } + + protected List listFilteredBy(Function filter) { + return results.entrySet().stream() + .filter(e -> filter.apply(e.getValue())) + .map(Map.Entry::getKey) + .toList(); + } + + @Override + public boolean erroredDependentsExist() { + if (hasErroredDependents == null) { + hasErroredDependents = !getErroredDependents().isEmpty(); + } + return hasErroredDependents; + } + + @Override + public void throwAggregateExceptionIfErrorsPresent() { + if (erroredDependentsExist()) { + throw new AggregatedOperatorException( + "Exception(s) during workflow execution.", + getErroredDependentsStream() + .collect(Collectors.toMap(e -> e.getKey().name(), e -> e.getValue().error))); + } + } + + @SuppressWarnings("UnusedReturnValue") + static class DetailBuilder { + private Exception error; + private ReconcileResult reconcileResult; + private DetailedCondition.Result activationConditionResult; + private DetailedCondition.Result deletePostconditionResult; + private DetailedCondition.Result readyPostconditionResult; + private DetailedCondition.Result reconcilePostconditionResult; + private boolean deleted; + private boolean visited; + private boolean markedForDelete; + + Detail build() { + return new Detail<>( + error, + reconcileResult, + activationConditionResult, + deletePostconditionResult, + readyPostconditionResult, + reconcilePostconditionResult, + deleted, + visited, + markedForDelete); + } + + DetailBuilder withResultForCondition( + ConditionWithType conditionWithType, DetailedCondition.Result conditionResult) { + switch (conditionWithType.type()) { + case ACTIVATION -> activationConditionResult = conditionResult; + case DELETE -> deletePostconditionResult = conditionResult; + case READY -> readyPostconditionResult = conditionResult; + case RECONCILE -> reconcilePostconditionResult = conditionResult; + default -> + throw new IllegalStateException("Unexpected condition type: " + conditionWithType); + } + return this; + } + + DetailBuilder withError(Exception error) { + this.error = error; + return this; + } + + DetailBuilder withReconcileResult(ReconcileResult reconcileResult) { + this.reconcileResult = reconcileResult; + return this; + } + + DetailBuilder markAsDeleted() { + this.deleted = true; + return this; + } + + public boolean hasError() { + return error != null; + } + + public boolean hasPostDeleteConditionNotMet() { + return deletePostconditionResult != null && !deletePostconditionResult.isSuccess(); + } + + public boolean isReady() { + return readyPostconditionResult == null || readyPostconditionResult.isSuccess(); + } + + DetailBuilder markAsVisited() { + visited = true; + return this; + } + + public boolean isVisited() { + return visited; + } + + public boolean isMarkedForDelete() { + return markedForDelete; + } + + DetailBuilder markForDelete() { + markedForDelete = true; + return this; + } + } + + record Detail( + Exception error, + ReconcileResult reconcileResult, + DetailedCondition.Result activationConditionResult, + DetailedCondition.Result deletePostconditionResult, + DetailedCondition.Result readyPostconditionResult, + DetailedCondition.Result reconcilePostconditionResult, + boolean deleted, + boolean visited, + boolean markedForDelete) { + + boolean isConditionWithTypeMet(Condition.Type conditionType) { + return getResultForConditionWithType(conditionType) + .map(DetailedCondition.Result::isSuccess) + .orElse(true); + } + + Optional> getResultForConditionWithType( + Condition.Type conditionType) { + return switch (conditionType) { + case ACTIVATION -> Optional.ofNullable(activationConditionResult); + case DELETE -> Optional.ofNullable(deletePostconditionResult); + case READY -> Optional.ofNullable(readyPostconditionResult); + case RECONCILE -> Optional.ofNullable(reconcilePostconditionResult); + }; + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationCondition.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationCondition.java new file mode 100644 index 0000000000..8e7c987c69 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationCondition.java @@ -0,0 +1,125 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +/** + * A generic CRD checking activation condition. Makes sure that the CRD is not checked unnecessarily + * even used in multiple condition. By default, it checks CRD at most 10 times with a delay at least + * 10 seconds. To fully customize CRD check trigger behavior you can extend this class and override + * the {@link CRDPresentActivationCondition#shouldCheckStateNow(CRDCheckState)} method. + * + * @param the resource type associated with the CRD to check for presence + * @param

the primary resource type associated with the reconciler processing dependents + * associated with this condition + */ +public class CRDPresentActivationCondition + implements Condition { + + public static final int DEFAULT_CRD_CHECK_LIMIT = 10; + public static final Duration DEFAULT_CRD_CHECK_INTERVAL = Duration.ofSeconds(10); + + private static final Map crdPresenceCache = new ConcurrentHashMap<>(); + + private final CRDPresentChecker crdPresentChecker; + private final int checkLimit; + private final Duration crdCheckInterval; + + public CRDPresentActivationCondition() { + this(DEFAULT_CRD_CHECK_LIMIT, DEFAULT_CRD_CHECK_INTERVAL); + } + + public CRDPresentActivationCondition(int checkLimit, Duration crdCheckInterval) { + this(new CRDPresentChecker(), checkLimit, crdCheckInterval); + } + + // for testing purposes only + CRDPresentActivationCondition( + CRDPresentChecker crdPresentChecker, int checkLimit, Duration crdCheckInterval) { + this.crdPresentChecker = crdPresentChecker; + this.checkLimit = checkLimit; + this.crdCheckInterval = crdCheckInterval; + } + + @Override + public boolean isMet(DependentResource dependentResource, P primary, Context

context) { + + var resourceClass = dependentResource.resourceType(); + final var crdName = HasMetadata.getFullResourceName(resourceClass); + + var crdCheckState = crdPresenceCache.computeIfAbsent(crdName, g -> new CRDCheckState()); + + synchronized (crdCheckState) { + if (shouldCheckStateNow(crdCheckState)) { + boolean isPresent = crdPresentChecker.checkIfCRDPresent(crdName, context.getClient()); + crdCheckState.checkedNow(isPresent); + } + } + + if (crdCheckState.isCrdPresent() == null) { + throw new IllegalStateException("State should be already checked at this point."); + } + return crdCheckState.isCrdPresent(); + } + + /** Override this method to fine tune when the crd state should be refreshed; */ + protected boolean shouldCheckStateNow(CRDCheckState crdCheckState) { + if (crdCheckState.isCrdPresent() == null) { + return true; + } + // assumption is that if CRD is present, it is not deleted anymore + if (crdCheckState.isCrdPresent()) { + return false; + } + if (crdCheckState.getCheckCount() >= checkLimit) { + return false; + } + if (crdCheckState.getLastChecked() == null) { + return true; + } + return LocalDateTime.now().isAfter(crdCheckState.getLastChecked().plus(crdCheckInterval)); + } + + public static class CRDCheckState { + private Boolean crdPresent; + private LocalDateTime lastChecked; + private int checkCount = 0; + + public void checkedNow(boolean crdPresent) { + this.crdPresent = crdPresent; + lastChecked = LocalDateTime.now(); + checkCount++; + } + + public Boolean isCrdPresent() { + return crdPresent; + } + + public LocalDateTime getLastChecked() { + return lastChecked; + } + + public int getCheckCount() { + return checkCount; + } + } + + public static class CRDPresentChecker { + boolean checkIfCRDPresent(String crdName, KubernetesClient client) { + return client.resources(CustomResourceDefinition.class).withName(crdName).get() != null; + } + } + + /** For testing purposes only */ + public static void clearState() { + crdPresenceCache.clear(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/Condition.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/Condition.java new file mode 100644 index 0000000000..a0af3ddf5c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/Condition.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +public interface Condition { + + enum Type { + ACTIVATION, + DELETE, + READY, + RECONCILE + } + + /** + * Checks whether a condition holds true for a given {@link + * io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource} based on the observed + * cluster state. + * + * @param dependentResource for which the condition applies to + * @param primary the primary resource being considered + * @param context the current reconciliation {@link Context} + * @return {@code true} if the condition holds, {@code false} otherwise + */ + boolean isMet(DependentResource dependentResource, P primary, Context

context); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ConditionWithType.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ConditionWithType.java new file mode 100644 index 0000000000..97e00a760c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ConditionWithType.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +class ConditionWithType implements DetailedCondition { + private final Condition condition; + private final Type type; + + ConditionWithType(Condition condition, Type type) { + this.condition = condition; + this.type = type; + } + + public Type type() { + return type; + } + + @SuppressWarnings("unchecked") + @Override + public Result detailedIsMet( + DependentResource dependentResource, P primary, Context

context) { + if (condition instanceof DetailedCondition detailedCondition) { + return detailedCondition.detailedIsMet(dependentResource, primary, context); + } else { + return Result.withoutResult(condition.isMet(dependentResource, primary, context)); + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultManagedWorkflow.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultManagedWorkflow.java new file mode 100644 index 0000000000..ed02ef8f4e --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultManagedWorkflow.java @@ -0,0 +1,134 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.EventSourceReferencer; +import io.javaoperatorsdk.operator.api.reconciler.dependent.NameSetter; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_VALUE_SET; + +@SuppressWarnings("rawtypes") +public class DefaultManagedWorkflow

implements ManagedWorkflow

{ + + private final Set topLevelResources; + private final Set bottomLevelResources; + private final List orderedSpecs; + private final boolean hasCleaner; + + protected DefaultManagedWorkflow(List orderedSpecs, boolean hasCleaner) { + this.hasCleaner = hasCleaner; + topLevelResources = new HashSet<>(orderedSpecs.size()); + bottomLevelResources = + orderedSpecs.stream().map(DependentResourceSpec::getName).collect(Collectors.toSet()); + this.orderedSpecs = orderedSpecs; + for (DependentResourceSpec spec : orderedSpecs) { + // add cycle detection? + if (spec.getDependsOn().isEmpty()) { + topLevelResources.add(spec.getName()); + } else { + for (String dependsOn : spec.getDependsOn()) { + bottomLevelResources.remove(dependsOn); + } + } + } + } + + @Override + @SuppressWarnings("unused") + public List getOrderedSpecs() { + return orderedSpecs; + } + + protected Set getTopLevelResources() { + return topLevelResources; + } + + protected Set getBottomLevelResources() { + return bottomLevelResources; + } + + List nodeNames() { + return orderedSpecs.stream().map(DependentResourceSpec::getName).collect(Collectors.toList()); + } + + @Override + public boolean hasCleaner() { + return hasCleaner; + } + + @Override + public boolean isEmpty() { + return orderedSpecs.isEmpty(); + } + + @Override + @SuppressWarnings("unchecked") + public Workflow

resolve(KubernetesClient client, ControllerConfiguration

configuration) { + final var alreadyResolved = new HashMap(orderedSpecs.size()); + for (DependentResourceSpec spec : orderedSpecs) { + final var dependentResource = resolve(spec, client, configuration); + final var node = + configuration + .getConfigurationService() + .dependentResourceFactory() + .createNodeFrom(spec, dependentResource); + alreadyResolved.put(dependentResource.name(), node); + spec.getDependsOn().forEach(depend -> node.addDependsOnRelation(alreadyResolved.get(depend))); + } + + final var bottom = + bottomLevelResources.stream().map(alreadyResolved::get).collect(Collectors.toSet()); + final var top = + topLevelResources.stream().map(alreadyResolved::get).collect(Collectors.toSet()); + return new DefaultWorkflow<>( + alreadyResolved, + bottom, + top, + configuration.getWorkflowSpec().map(w -> !w.handleExceptionsInReconciler()).orElseThrow(), + hasCleaner); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private DependentResource resolve( + DependentResourceSpec spec, + KubernetesClient client, + ControllerConfiguration

configuration) { + final DependentResource dependentResource = + configuration + .getConfigurationService() + .dependentResourceFactory() + .createFrom(spec, configuration); + + final var name = spec.getName(); + if (name != null && !NO_VALUE_SET.equals(name) && dependentResource instanceof NameSetter) { + ((NameSetter) dependentResource).setName(name); + } + + spec.getUseEventSourceWithName() + .ifPresent( + esName -> { + if (dependentResource instanceof EventSourceReferencer) { + ((EventSourceReferencer) dependentResource).useEventSourceWithName(esName); + } else { + throw new IllegalStateException( + "DependentResource " + + spec + + " wants to use EventSource named " + + esName + + " but doesn't implement support for this feature by implementing " + + EventSourceReferencer.class.getSimpleName()); + } + }); + + return dependentResource; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultResult.java new file mode 100644 index 0000000000..cc63f637cf --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultResult.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +public class DefaultResult implements DetailedCondition.Result { + private final T result; + private final boolean success; + + public DefaultResult(boolean success, T result) { + this.result = result; + this.success = success; + } + + @Override + public T getDetail() { + return result; + } + + @Override + public boolean isSuccess() { + return success; + } + + @Override + public String toString() { + return asString(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultWorkflow.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultWorkflow.java new file mode 100644 index 0000000000..3ee1499543 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultWorkflow.java @@ -0,0 +1,169 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; + +import static io.javaoperatorsdk.operator.api.reconciler.dependent.managed.DefaultManagedWorkflowAndDependentResourceContext.CLEANUP_RESULT_KEY; +import static io.javaoperatorsdk.operator.api.reconciler.dependent.managed.DefaultManagedWorkflowAndDependentResourceContext.RECONCILE_RESULT_KEY; + +/** + * Dependents definition: so if B depends on A, the B is dependent of A. + * + * @param

primary resource + */ +@SuppressWarnings("rawtypes") +class DefaultWorkflow

implements Workflow

{ + + private final Map dependentResourceNodes; + private final Set topLevelResources; + private final Set bottomLevelResource; + + private final boolean throwExceptionAutomatically; + private final boolean hasCleaner; + + DefaultWorkflow(Set dependentResourceNodes) { + this(dependentResourceNodes, THROW_EXCEPTION_AUTOMATICALLY_DEFAULT, false); + } + + DefaultWorkflow( + Set dependentResourceNodes, + boolean throwExceptionAutomatically, + boolean hasCleaner) { + this.throwExceptionAutomatically = throwExceptionAutomatically; + this.hasCleaner = hasCleaner; + + if (dependentResourceNodes == null) { + this.topLevelResources = Collections.emptySet(); + this.bottomLevelResource = Collections.emptySet(); + this.dependentResourceNodes = Collections.emptyMap(); + } else { + this.topLevelResources = new HashSet<>(dependentResourceNodes.size()); + this.bottomLevelResource = new HashSet<>(dependentResourceNodes); + this.dependentResourceNodes = toMap(dependentResourceNodes); + } + } + + protected DefaultWorkflow( + Map dependentResourceNodes, + Set bottomLevelResource, + Set topLevelResources, + boolean throwExceptionAutomatically, + boolean hasCleaner) { + this.throwExceptionAutomatically = throwExceptionAutomatically; + this.hasCleaner = hasCleaner; + this.topLevelResources = topLevelResources; + this.bottomLevelResource = bottomLevelResource; + this.dependentResourceNodes = dependentResourceNodes; + } + + @SuppressWarnings("unchecked") + private Map toMap(Set nodes) { + if (nodes == null || nodes.isEmpty()) { + return Collections.emptyMap(); + } + + final var map = new HashMap(nodes.size()); + for (DependentResourceNode node : nodes) { + // add cycle detection? + if (node.getDependsOn().isEmpty()) { + topLevelResources.add(node); + } else { + for (DependentResourceNode dependsOn : (List) node.getDependsOn()) { + bottomLevelResource.remove(dependsOn); + } + } + map.put(node.getDependentResource().name(), node); + } + if (topLevelResources.isEmpty()) { + throw new IllegalStateException( + "No top-level dependent resources found. This might indicate a cyclic Set of" + + " DependentResourceNode has been provided."); + } + return map; + } + + @Override + public WorkflowReconcileResult reconcile(P primary, Context

context) { + WorkflowReconcileExecutor

workflowReconcileExecutor = + new WorkflowReconcileExecutor<>(this, primary, context); + var result = workflowReconcileExecutor.reconcile(); + context.managedWorkflowAndDependentResourceContext().put(RECONCILE_RESULT_KEY, result); + if (throwExceptionAutomatically) { + result.throwAggregateExceptionIfErrorsPresent(); + } + return result; + } + + @Override + public WorkflowCleanupResult cleanup(P primary, Context

context) { + WorkflowCleanupExecutor

workflowCleanupExecutor = + new WorkflowCleanupExecutor<>(this, primary, context); + var result = workflowCleanupExecutor.cleanup(); + context.managedWorkflowAndDependentResourceContext().put(CLEANUP_RESULT_KEY, result); + if (throwExceptionAutomatically) { + result.throwAggregateExceptionIfErrorsPresent(); + } + return result; + } + + public Set getTopLevelDependentResources() { + return topLevelResources; + } + + public Set getBottomLevelDependentResources() { + return bottomLevelResource; + } + + @Override + public boolean hasCleaner() { + return hasCleaner; + } + + static boolean isDeletable(Class drClass) { + final var isDeleter = Deleter.class.isAssignableFrom(drClass); + if (!isDeleter) { + return false; + } + + if (KubernetesDependentResource.class.isAssignableFrom(drClass)) { + return !GarbageCollected.class.isAssignableFrom(drClass); + } + return true; + } + + @Override + public boolean isEmpty() { + return dependentResourceNodes.isEmpty(); + } + + @Override + public int size() { + return dependentResourceNodes.size(); + } + + @Override + public Map getDependentResourcesByName() { + final var resources = new HashMap(dependentResourceNodes.size()); + dependentResourceNodes.forEach( + (name, node) -> resources.put(name, node.getDependentResource())); + return resources; + } + + public List getDependentResourcesWithoutActivationCondition() { + return dependentResourceNodes.values().stream() + .filter(n -> n.getActivationCondition().isEmpty()) + .map(DependentResourceNode::getDependentResource) + .toList(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultWorkflowCleanupResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultWorkflowCleanupResult.java new file mode 100644 index 0000000000..951ff98bca --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultWorkflowCleanupResult.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.List; +import java.util.Map; + +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +@SuppressWarnings("rawtypes") +class DefaultWorkflowCleanupResult extends BaseWorkflowResult implements WorkflowCleanupResult { + private Boolean allPostConditionsMet; + + DefaultWorkflowCleanupResult(Map> results) { + super(results); + } + + public List getDeleteCalledOnDependents() { + return listFilteredBy(BaseWorkflowResult.Detail::deleted); + } + + public List getPostConditionNotMetDependents() { + return listFilteredBy(detail -> !detail.isConditionWithTypeMet(Condition.Type.DELETE)); + } + + public boolean allPostConditionsMet() { + if (allPostConditionsMet == null) { + allPostConditionsMet = getPostConditionNotMetDependents().isEmpty(); + } + return allPostConditionsMet; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultWorkflowReconcileResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultWorkflowReconcileResult.java new file mode 100644 index 0000000000..c7ed8290d0 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DefaultWorkflowReconcileResult.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +@SuppressWarnings("rawtypes") +class DefaultWorkflowReconcileResult extends BaseWorkflowResult implements WorkflowReconcileResult { + DefaultWorkflowReconcileResult(Map> results) { + super(results); + } + + public List getReconciledDependents() { + return listFilteredBy(detail -> detail.reconcileResult() != null); + } + + public List getNotReadyDependents() { + return listFilteredBy(detail -> !detail.isConditionWithTypeMet(Condition.Type.READY)); + } + + public Optional getNotReadyDependentResult( + DependentResource dependentResource, Class expectedResultType) { + return getDependentConditionResult(dependentResource, Condition.Type.READY, expectedResultType); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DependentResourceNode.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DependentResourceNode.java new file mode 100644 index 0000000000..c456b44ef2 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DependentResourceNode.java @@ -0,0 +1,125 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +@SuppressWarnings("rawtypes") +public class DependentResourceNode { + + private final List dependsOn = new LinkedList<>(); + private final List parents = new LinkedList<>(); + + private ConditionWithType reconcilePrecondition; + private ConditionWithType deletePostcondition; + private ConditionWithType readyPostcondition; + private ConditionWithType activationCondition; + private final DependentResource dependentResource; + + DependentResourceNode(DependentResource dependentResource) { + this(null, null, null, null, dependentResource); + } + + public DependentResourceNode( + Condition reconcilePrecondition, + Condition deletePostcondition, + Condition readyPostcondition, + Condition activationCondition, + DependentResource dependentResource) { + setReconcilePrecondition(reconcilePrecondition); + setDeletePostcondition(deletePostcondition); + setReadyPostcondition(readyPostcondition); + setActivationCondition(activationCondition); + this.dependentResource = dependentResource; + } + + public List getDependsOn() { + return dependsOn; + } + + void addParent(DependentResourceNode parent) { + parents.add(parent); + } + + void addDependsOnRelation(DependentResourceNode node) { + node.addParent(this); + dependsOn.add(node); + } + + public List getParents() { + return parents; + } + + public Optional> getReconcilePrecondition() { + return Optional.ofNullable(reconcilePrecondition); + } + + public Optional> getDeletePostcondition() { + return Optional.ofNullable(deletePostcondition); + } + + public Optional> getActivationCondition() { + return Optional.ofNullable(activationCondition); + } + + public Optional> getReadyPostcondition() { + return Optional.ofNullable(readyPostcondition); + } + + void setReconcilePrecondition(Condition reconcilePrecondition) { + this.reconcilePrecondition = + reconcilePrecondition == null + ? null + : new ConditionWithType<>(reconcilePrecondition, Condition.Type.RECONCILE); + } + + void setDeletePostcondition(Condition deletePostcondition) { + this.deletePostcondition = + deletePostcondition == null + ? null + : new ConditionWithType<>(deletePostcondition, Condition.Type.DELETE); + } + + void setActivationCondition(Condition activationCondition) { + this.activationCondition = + activationCondition == null + ? null + : new ConditionWithType<>(activationCondition, Condition.Type.ACTIVATION); + } + + void setReadyPostcondition(Condition readyPostcondition) { + this.readyPostcondition = + readyPostcondition == null + ? null + : new ConditionWithType<>(readyPostcondition, Condition.Type.READY); + } + + public DependentResource getDependentResource() { + return dependentResource; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + DependentResourceNode that = (DependentResourceNode) o; + return this.getDependentResource().name().equals(that.getDependentResource().name()); + } + + @Override + public int hashCode() { + return this.getDependentResource().name().hashCode(); + } + + @Override + public String toString() { + return "DependentResourceNode{" + getDependentResource() + '}'; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DetailedCondition.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DetailedCondition.java new file mode 100644 index 0000000000..578d921e28 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/DetailedCondition.java @@ -0,0 +1,90 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +/** + * A condition that can return extra information in addition of whether it is met or not. + * + * @param the resource type this condition applies to + * @param

the primary resource type associated with the dependent workflow this condition is + * part of + * @param the type of the extra information returned by the condition + */ +public interface DetailedCondition extends Condition { + + /** + * Checks whether a condition holds true for the specified {@link DependentResource}, returning + * additional information as needed. + * + * @param dependentResource the {@link DependentResource} for which we want to check the condition + * @param primary the primary resource being considered + * @param context the current reconciliation {@link Context} + * @return a {@link Result} instance containing the result of the evaluation of the condition as + * well as additional detail + * @see Condition#isMet(DependentResource, HasMetadata, Context) + */ + Result detailedIsMet(DependentResource dependentResource, P primary, Context

context); + + @Override + default boolean isMet(DependentResource dependentResource, P primary, Context

context) { + return detailedIsMet(dependentResource, primary, context).isSuccess(); + } + + /** + * Holds a more detailed {@link Condition} result. + * + * @param the type of the extra information provided in condition evaluation + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + interface Result { + /** A result expressing a condition has been met without extra information */ + Result metWithoutResult = new DefaultResult(true, null); + + /** A result expressing a condition has not been met without extra information */ + Result unmetWithoutResult = new DefaultResult(false, null); + + /** + * Creates a {@link Result} without extra information + * + * @param success whether or not the condition has been met + * @return a {@link Result} without extra information + */ + static Result withoutResult(boolean success) { + return success ? metWithoutResult : unmetWithoutResult; + } + + /** + * Creates a {@link Result} with the specified condition evaluation result and extra information + * + * @param success whether or not the condition has been met + * @param detail the extra information that the condition provided during its evaluation + * @return a {@link Result} with the specified condition evaluation result and extra information + * @param the type of the extra information provided by the condition + */ + static Result withResult(boolean success, T detail) { + return new DefaultResult<>(success, detail); + } + + default String asString() { + return "Detail: " + getDetail() + " met: " + isSuccess(); + } + + /** + * The extra information provided by the associated {@link DetailedCondition} during its + * evaluation + * + * @return extra information provided by the associated {@link DetailedCondition} during its + * evaluation or {@code null} if none was provided + */ + T getDetail(); + + /** + * Whether the associated condition held true + * + * @return {@code true} if the associated condition was met, {@code false} otherwise + */ + boolean isSuccess(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/KubernetesResourceDeletedCondition.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/KubernetesResourceDeletedCondition.java new file mode 100644 index 0000000000..3cf464d46b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/KubernetesResourceDeletedCondition.java @@ -0,0 +1,21 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +/* A condition implementation meant to be used as a delete post-condition on Kubernetes dependent + * resources to prevent the workflow from proceeding until the associated resource is actually + * deleted from the server. + */ +public class KubernetesResourceDeletedCondition implements Condition { + + @Override + public boolean isMet( + DependentResource dependentResource, + HasMetadata primary, + Context context) { + var optionalResource = dependentResource.getSecondaryResource(primary, context); + return optionalResource.isEmpty(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflow.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflow.java new file mode 100644 index 0000000000..2bd8d749a4 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflow.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.Collections; +import java.util.List; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; + +public interface ManagedWorkflow

{ + + @SuppressWarnings({"unused", "rawtypes"}) + default List getOrderedSpecs() { + return Collections.emptyList(); + } + + default boolean hasCleaner() { + return false; + } + + default boolean isEmpty() { + return true; + } + + Workflow

resolve(KubernetesClient client, ControllerConfiguration

configuration); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowFactory.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowFactory.java new file mode 100644 index 0000000000..365dcef138 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowFactory.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.Optional; + +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec; + +public interface ManagedWorkflowFactory> { + + @SuppressWarnings({"rawtypes", "unchecked"}) + ManagedWorkflowFactory DEFAULT = + (configuration) -> { + final Optional workflowSpec = configuration.getWorkflowSpec(); + if (workflowSpec.isEmpty()) { + return (ManagedWorkflow) (client, configuration1) -> new DefaultWorkflow(null); + } + ManagedWorkflowSupport support = new ManagedWorkflowSupport(); + return support.createWorkflow(workflowSpec.orElseThrow()); + }; + + @SuppressWarnings("rawtypes") + ManagedWorkflow workflowFor(C configuration); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowSupport.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowSupport.java new file mode 100644 index 0000000000..012dcdff56 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowSupport.java @@ -0,0 +1,167 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec; + +@SuppressWarnings({"rawtypes", "unchecked"}) +public class ManagedWorkflowSupport { + + public void checkForNameDuplication(List dependentResourceSpecs) { + if (dependentResourceSpecs == null) { + return; + } + final var size = dependentResourceSpecs.size(); + if (size == 0) { + return; + } + + final var uniqueNames = new HashSet<>(size); + final var duplicatedNames = new HashSet<>(size); + dependentResourceSpecs.forEach( + spec -> { + final var name = spec.getName(); + if (!uniqueNames.add(name)) { + duplicatedNames.add(name); + } + }); + if (!duplicatedNames.isEmpty()) { + throw new OperatorException("Duplicated dependent resource name(s): " + duplicatedNames); + } + } + + public

ManagedWorkflow

createWorkflow(WorkflowSpec workflowSpec) { + return createAsDefault(workflowSpec.getDependentResourceSpecs()); + } + +

DefaultManagedWorkflow

createAsDefault( + List dependentResourceSpecs) { + final boolean[] cleanerHolder = {false}; + var orderedResourceSpecs = orderAndDetectCycles(dependentResourceSpecs, cleanerHolder); + return new DefaultManagedWorkflow<>(orderedResourceSpecs, cleanerHolder[0]); + } + + /** + * @param dependentResourceSpecs list of specs + * @return top-bottom ordered resources that can be added safely to workflow + * @throws OperatorException if there is a cycle in the dependencies + */ + private List orderAndDetectCycles( + List dependentResourceSpecs, boolean[] cleanerHolder) { + + final var drInfosByName = createDRInfos(dependentResourceSpecs); + final var orderedSpecs = new ArrayList(dependentResourceSpecs.size()); + final var alreadyVisited = new HashSet(); + var toVisit = getTopDependentResources(dependentResourceSpecs); + + while (!toVisit.isEmpty()) { + final var toVisitNext = new HashSet(); + toVisit.forEach( + dr -> { + if (cleanerHolder != null) { + cleanerHolder[0] = + cleanerHolder[0] || DefaultWorkflow.isDeletable(dr.getDependentResourceClass()); + } + final var name = dr.getName(); + var drInfo = drInfosByName.get(name); + if (drInfo != null) { + drInfo.waitingForCompletion.forEach( + spec -> { + if (isReadyForVisit(spec, alreadyVisited, name)) { + toVisitNext.add(spec); + } + }); + orderedSpecs.add(dr); + } + alreadyVisited.add(name); + }); + + toVisit = toVisitNext; + } + + if (orderedSpecs.size() != dependentResourceSpecs.size()) { + // could provide improved message where the exact cycles are made visible + throw new OperatorException("Cycle(s) between dependent resources."); + } + return orderedSpecs; + } + + /** + * @param dependentResourceSpecs list of specs + * @return top-bottom ordered resources that can be added safely to workflow + * @throws OperatorException if there is a cycle in the dependencies + */ + public List orderAndDetectCycles( + List dependentResourceSpecs) { + return orderAndDetectCycles(dependentResourceSpecs, null); + } + + private static class DRInfo { + + private final DependentResourceSpec spec; + private final List waitingForCompletion; + + private DRInfo(DependentResourceSpec spec) { + this.spec = spec; + this.waitingForCompletion = new LinkedList<>(); + } + + void add(DependentResourceSpec spec) { + waitingForCompletion.add(spec); + } + + String name() { + return spec.getName(); + } + } + + private boolean isReadyForVisit( + DependentResourceSpec dr, Set alreadyVisited, String alreadyPresentName) { + for (var name : dr.getDependsOn()) { + if (name.equals(alreadyPresentName)) { + continue; + } + if (!alreadyVisited.contains(name)) { + return false; + } + } + return true; + } + + private Set getTopDependentResources( + List dependentResourceSpecs) { + return dependentResourceSpecs.stream() + .filter(r -> r.getDependsOn().isEmpty()) + .collect(Collectors.toSet()); + } + + private Map createDRInfos(List dependentResourceSpecs) { + // first create mappings + final var infos = + dependentResourceSpecs.stream() + .map(DRInfo::new) + .collect(Collectors.toMap(DRInfo::name, Function.identity())); + + // then populate the reverse depends on information + dependentResourceSpecs.forEach( + spec -> + spec.getDependsOn() + .forEach( + name -> { + final var drInfo = infos.get(name); + drInfo.add(spec); + })); + + return infos; + } +} 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 new file mode 100644 index 0000000000..4efadff05f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/NodeExecutor.java @@ -0,0 +1,31 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +abstract class NodeExecutor implements Runnable { + + private final DependentResourceNode dependentResourceNode; + private final AbstractWorkflowExecutor

workflowExecutor; + + protected NodeExecutor( + DependentResourceNode dependentResourceNode, + AbstractWorkflowExecutor

workflowExecutor) { + this.dependentResourceNode = dependentResourceNode; + this.workflowExecutor = workflowExecutor; + } + + @Override + public void run() { + try { + doRun(dependentResourceNode); + + } catch (Exception e) { + // Exception is required because of Kotlin + workflowExecutor.handleExceptionInExecutor(dependentResourceNode, e); + } finally { + workflowExecutor.handleNodeExecutionFinish(dependentResourceNode); + } + } + + protected abstract void doRun(DependentResourceNode dependentResourceNode); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/Workflow.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/Workflow.java new file mode 100644 index 0000000000..02f7d9c89f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/Workflow.java @@ -0,0 +1,44 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +public interface Workflow

{ + + boolean THROW_EXCEPTION_AUTOMATICALLY_DEFAULT = true; + + default WorkflowReconcileResult reconcile(P primary, Context

context) { + throw new UnsupportedOperationException("Implement this"); + } + + default WorkflowCleanupResult cleanup(P primary, Context

context) { + throw new UnsupportedOperationException("Implement this"); + } + + default boolean hasCleaner() { + return false; + } + + default boolean isEmpty() { + return size() == 0; + } + + default int size() { + return getDependentResourcesByName().size(); + } + + @SuppressWarnings("rawtypes") + default Map getDependentResourcesByName() { + return Collections.emptyMap(); + } + + @SuppressWarnings("rawtypes") + default List getDependentResourcesWithoutActivationCondition() { + return Collections.emptyList(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowBuilder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowBuilder.java new file mode 100644 index 0000000000..e85434f10f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowBuilder.java @@ -0,0 +1,132 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +import static io.javaoperatorsdk.operator.processing.dependent.workflow.Workflow.THROW_EXCEPTION_AUTOMATICALLY_DEFAULT; + +@SuppressWarnings({"rawtypes", "unchecked"}) +public class WorkflowBuilder

{ + + private final Map> dependentResourceNodes = new HashMap<>(); + private boolean throwExceptionAutomatically = THROW_EXCEPTION_AUTOMATICALLY_DEFAULT; + private boolean isCleaner = false; + + public WorkflowNodeConfigurationBuilder addDependentResourceAndConfigure( + DependentResource dependentResource) { + final var currentNode = doAddDependentResource(dependentResource); + return new WorkflowNodeConfigurationBuilder(currentNode); + } + + public WorkflowBuilder

addDependentResource(DependentResource dependentResource) { + doAddDependentResource(dependentResource); + return this; + } + + private DependentResourceNode doAddDependentResource(DependentResource dependentResource) { + final var currentNode = new DependentResourceNode<>(dependentResource); + isCleaner = isCleaner || dependentResource.isDeletable(); + final var actualName = dependentResource.name(); + dependentResourceNodes.put(actualName, currentNode); + return currentNode; + } + + DependentResourceNode getNodeByDependentResource(DependentResource dependentResource) { + // first check by name + final var node = dependentResourceNodes.get(dependentResource.name()); + if (node != null) { + return node; + } else { + return dependentResourceNodes.values().stream() + .filter(dr -> dr.getDependentResource() == dependentResource) + .findFirst() + .orElseThrow(); + } + } + + public WorkflowBuilder

withThrowExceptionFurther(boolean throwExceptionFurther) { + this.throwExceptionAutomatically = throwExceptionFurther; + return this; + } + + public Workflow

build() { + return buildAsDefaultWorkflow(); + } + + DefaultWorkflow

buildAsDefaultWorkflow() { + return new DefaultWorkflow( + new HashSet<>(dependentResourceNodes.values()), throwExceptionAutomatically, isCleaner); + } + + public class WorkflowNodeConfigurationBuilder { + private final DependentResourceNode currentNode; + + private WorkflowNodeConfigurationBuilder(DependentResourceNode currentNode) { + this.currentNode = currentNode; + } + + public WorkflowBuilder

addDependentResource(DependentResource dependentResource) { + return WorkflowBuilder.this.addDependentResource(dependentResource); + } + + public WorkflowNodeConfigurationBuilder addDependentResourceAndConfigure( + DependentResource dependentResource) { + final var currentNode = WorkflowBuilder.this.doAddDependentResource(dependentResource); + return new WorkflowNodeConfigurationBuilder(currentNode); + } + + public Workflow

build() { + return WorkflowBuilder.this.build(); + } + + DefaultWorkflow

buildAsDefaultWorkflow() { + return WorkflowBuilder.this.buildAsDefaultWorkflow(); + } + + public WorkflowBuilder

withThrowExceptionFurther(boolean throwExceptionFurther) { + return WorkflowBuilder.this.withThrowExceptionFurther(throwExceptionFurther); + } + + public WorkflowNodeConfigurationBuilder dependsOn(Set dependentResources) { + for (var dependentResource : dependentResources) { + var dependsOn = getNodeByDependentResource(dependentResource); + currentNode.addDependsOnRelation(dependsOn); + } + return this; + } + + public WorkflowNodeConfigurationBuilder dependsOn(DependentResource... dependentResources) { + if (dependentResources != null) { + return dependsOn(new HashSet<>(Arrays.asList(dependentResources))); + } + return this; + } + + public WorkflowNodeConfigurationBuilder withReconcilePrecondition( + Condition reconcilePrecondition) { + currentNode.setReconcilePrecondition(reconcilePrecondition); + return this; + } + + public WorkflowNodeConfigurationBuilder withReadyPostcondition(Condition readyPostcondition) { + currentNode.setReadyPostcondition(readyPostcondition); + return this; + } + + public WorkflowNodeConfigurationBuilder withDeletePostcondition(Condition deletePostcondition) { + currentNode.setDeletePostcondition(deletePostcondition); + return this; + } + + public WorkflowNodeConfigurationBuilder withActivationCondition(Condition activationCondition) { + currentNode.setActivationCondition(activationCondition); + return this; + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowCleanupExecutor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowCleanupExecutor.java new file mode 100644 index 0000000000..29274bb7b9 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowCleanupExecutor.java @@ -0,0 +1,137 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; + +@SuppressWarnings("rawtypes") +class WorkflowCleanupExecutor

extends AbstractWorkflowExecutor

{ + + private static final Logger log = LoggerFactory.getLogger(WorkflowCleanupExecutor.class); + private static final String CLEANUP = "cleanup"; + + WorkflowCleanupExecutor(DefaultWorkflow

workflow, P primary, Context

context) { + super(workflow, primary, context); + } + + public synchronized WorkflowCleanupResult cleanup() { + for (DependentResourceNode dependentResourceNode : + workflow.getBottomLevelDependentResources()) { + handleCleanup(dependentResourceNode); + } + waitForScheduledExecutionsToRun(); + return createCleanupResult(); + } + + @Override + protected Logger logger() { + return log; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private synchronized void handleCleanup(DependentResourceNode dependentResourceNode) { + log.debug("Considering for cleanup: {} primaryID: {}", dependentResourceNode, primaryID); + + final var alreadyVisited = alreadyVisited(dependentResourceNode); + final var executingNow = isExecutingNow(dependentResourceNode); + final var waitingOnDependents = !allDependentsCleaned(dependentResourceNode); + final var hasErroredDependent = hasErroredDependent(dependentResourceNode); + if (waitingOnDependents || alreadyVisited || executingNow || hasErroredDependent) { + if (log.isDebugEnabled()) { + final var causes = new ArrayList(); + if (alreadyVisited) { + causes.add("already visited"); + } + if (executingNow) { + causes.add("executing now"); + } + if (waitingOnDependents) { + causes.add("waiting on dependents"); + } + if (hasErroredDependent) { + causes.add("errored dependent"); + } + log.debug( + "Skipping: {} primaryID: {} causes: {}", + dependentResourceNode, + primaryID, + String.join(", ", causes)); + } + return; + } + + submit(dependentResourceNode, new CleanupExecutor<>(dependentResourceNode), CLEANUP); + } + + private class CleanupExecutor extends NodeExecutor { + + private CleanupExecutor(DependentResourceNode drn) { + super(drn, WorkflowCleanupExecutor.this); + } + + @Override + @SuppressWarnings("unchecked") + protected void doRun(DependentResourceNode dependentResourceNode) { + final var active = + isConditionMet(dependentResourceNode.getActivationCondition(), dependentResourceNode); + registerOrDeregisterEventSourceBasedOnActivation(active, dependentResourceNode); + + boolean deletePostConditionMet = true; + if (active) { + final var dependentResource = dependentResourceNode.getDependentResource(); + if (dependentResource.isDeletable()) { + ((Deleter

) dependentResource).delete(primary, context); + createOrGetResultFor(dependentResourceNode).markAsDeleted(); + } + + deletePostConditionMet = + isConditionMet(dependentResourceNode.getDeletePostcondition(), dependentResourceNode); + } + + createOrGetResultFor(dependentResourceNode).markAsVisited(); + + if (deletePostConditionMet) { + handleDependentCleaned(dependentResourceNode); + } + } + } + + private synchronized void handleDependentCleaned( + DependentResourceNode dependentResourceNode) { + var dependOns = dependentResourceNode.getDependsOn(); + if (dependOns != null) { + dependOns.forEach( + d -> { + log.debug( + "Handle cleanup for dependent: {} of parent: {} primaryID: {}", + d, + dependentResourceNode, + primaryID); + handleCleanup(d); + }); + } + } + + @SuppressWarnings("unchecked") + private boolean allDependentsCleaned(DependentResourceNode dependentResourceNode) { + List parents = dependentResourceNode.getParents(); + return parents.isEmpty() + || parents.stream().allMatch(d -> alreadyVisited(d) && !postDeleteConditionNotMet(d)); + } + + @SuppressWarnings("unchecked") + private boolean hasErroredDependent(DependentResourceNode dependentResourceNode) { + List parents = dependentResourceNode.getParents(); + return !parents.isEmpty() && parents.stream().anyMatch(this::isInError); + } + + private WorkflowCleanupResult createCleanupResult() { + return new DefaultWorkflowCleanupResult(asDetails()); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowCleanupResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowCleanupResult.java new file mode 100644 index 0000000000..1333270b47 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowCleanupResult.java @@ -0,0 +1,22 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.List; + +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +@SuppressWarnings("rawtypes") +public interface WorkflowCleanupResult extends WorkflowResult { + WorkflowCleanupResult EMPTY = new WorkflowCleanupResult() {}; + + default List getDeleteCalledOnDependents() { + return List.of(); + } + + default List getPostConditionNotMetDependents() { + return List.of(); + } + + default boolean allPostConditionsMet() { + return true; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileExecutor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileExecutor.java new file mode 100644 index 0000000000..9e29305b51 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileExecutor.java @@ -0,0 +1,265 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; + +@SuppressWarnings({"rawtypes", "unchecked"}) +class WorkflowReconcileExecutor

extends AbstractWorkflowExecutor

{ + + private static final Logger log = LoggerFactory.getLogger(WorkflowReconcileExecutor.class); + private static final String RECONCILE = "reconcile"; + private static final String DELETE = "delete"; + + public WorkflowReconcileExecutor(DefaultWorkflow

workflow, P primary, Context

context) { + super(workflow, primary, context); + } + + public synchronized WorkflowReconcileResult reconcile() { + for (DependentResourceNode dependentResourceNode : workflow.getTopLevelDependentResources()) { + handleReconcile(dependentResourceNode); + } + waitForScheduledExecutionsToRun(); + return createReconcileResult(); + } + + @Override + protected Logger logger() { + return log; + } + + private synchronized void handleReconcile(DependentResourceNode dependentResourceNode) { + log.debug("Considering for reconcile: {} primaryID: {}", dependentResourceNode, primaryID); + + final var alreadyVisited = alreadyVisited(dependentResourceNode); + final var executingNow = isExecutingNow(dependentResourceNode); + final var isWaitingOnParents = !allParentsReconciledAndReady(dependentResourceNode); + final var isMarkedForDelete = isMarkedForDelete(dependentResourceNode); + final var hasErroredParent = hasErroredParent(dependentResourceNode); + if (isWaitingOnParents + || alreadyVisited + || executingNow + || isMarkedForDelete + || hasErroredParent) { + if (log.isDebugEnabled()) { + final var causes = new ArrayList(); + if (alreadyVisited) { + causes.add("already visited"); + } + if (executingNow) { + causes.add("executing now"); + } + if (isMarkedForDelete) { + causes.add("marked for delete"); + } + if (isWaitingOnParents) { + causes.add("waiting on parents"); + } + if (hasErroredParent) { + causes.add("errored parent"); + } + log.debug( + "Skipping: {} primaryID: {} causes: {}", + dependentResourceNode, + primaryID, + String.join(", ", causes)); + } + return; + } + + boolean activationConditionMet = + isConditionMet(dependentResourceNode.getActivationCondition(), dependentResourceNode); + registerOrDeregisterEventSourceBasedOnActivation(activationConditionMet, dependentResourceNode); + + boolean reconcileConditionMet = true; + if (activationConditionMet) { + reconcileConditionMet = + isConditionMet(dependentResourceNode.getReconcilePrecondition(), dependentResourceNode); + } + if (!reconcileConditionMet || !activationConditionMet) { + handleReconcileOrActivationConditionNotMet(dependentResourceNode, activationConditionMet); + } else { + submit(dependentResourceNode, new NodeReconcileExecutor<>(dependentResourceNode), RECONCILE); + } + } + + private synchronized void handleDelete(DependentResourceNode dependentResourceNode) { + log.debug("Submitting for delete: {}", dependentResourceNode); + + final var alreadyVisited = alreadyVisited(dependentResourceNode); + final var executingNow = isExecutingNow(dependentResourceNode); + final var isNotMarkedForDelete = !isMarkedForDelete(dependentResourceNode); + final var isWaitingOnDependents = !allDependentsDeletedAlready(dependentResourceNode); + if (isNotMarkedForDelete || alreadyVisited || executingNow || isWaitingOnDependents) { + if (log.isDebugEnabled()) { + final var causes = new ArrayList(); + if (alreadyVisited) { + causes.add("already visited"); + } + if (executingNow) { + causes.add("executing now"); + } + if (isNotMarkedForDelete) { + causes.add("not marked for delete"); + } + if (isWaitingOnDependents) { + causes.add("waiting on dependents"); + } + log.debug( + "Skipping submit for delete of: {} primaryID: {} causes: {}", + dependentResourceNode, + primaryID, + String.join(", ", causes)); + } + return; + } + + submit(dependentResourceNode, new NodeDeleteExecutor<>(dependentResourceNode), DELETE); + } + + private boolean allDependentsDeletedAlready(DependentResourceNode dependentResourceNode) { + var dependents = dependentResourceNode.getParents(); + return dependents.stream() + .allMatch( + d -> alreadyVisited(d) && isReady(d) && !isInError(d) && !postDeleteConditionNotMet(d)); + } + + private class NodeReconcileExecutor extends NodeExecutor { + + private NodeReconcileExecutor(DependentResourceNode dependentResourceNode) { + super(dependentResourceNode, WorkflowReconcileExecutor.this); + } + + @Override + protected void doRun(DependentResourceNode dependentResourceNode) { + final var dependentResource = dependentResourceNode.getDependentResource(); + log.debug("Reconciling for primary: {} node: {} ", primaryID, dependentResourceNode); + ReconcileResult reconcileResult = dependentResource.reconcile(primary, context); + final var detailBuilder = createOrGetResultFor(dependentResourceNode); + + boolean isReadyPostconditionMet = + isConditionMet(dependentResourceNode.getReadyPostcondition(), dependentResourceNode); + detailBuilder.withReconcileResult(reconcileResult).markAsVisited(); + if (isReadyPostconditionMet) { + log.debug( + "Setting already reconciled for: {} primaryID: {}", dependentResourceNode, primaryID); + handleDependentsReconcile(dependentResourceNode); + } else { + log.debug("Setting already reconciled but not ready for: {}", dependentResourceNode); + } + } + } + + private class NodeDeleteExecutor extends NodeExecutor { + + private NodeDeleteExecutor(DependentResourceNode dependentResourceNode) { + super(dependentResourceNode, WorkflowReconcileExecutor.this); + } + + @Override + @SuppressWarnings("unchecked") + protected void doRun(DependentResourceNode dependentResourceNode) { + boolean deletePostConditionMet = true; + if (isConditionMet(dependentResourceNode.getActivationCondition(), dependentResourceNode)) { + // GarbageCollected status is irrelevant here, as this method is only called when a + // precondition does not hold, + // a deleter should be deleted even if it is otherwise garbage collected + final var dependentResource = dependentResourceNode.getDependentResource(); + if (dependentResource instanceof Deleter) { + ((Deleter

) dependentResource).delete(primary, context); + } + deletePostConditionMet = + isConditionMet(dependentResourceNode.getDeletePostcondition(), dependentResourceNode); + } + + createOrGetResultFor(dependentResourceNode).markAsVisited(); + if (deletePostConditionMet) { + handleDependentDeleted(dependentResourceNode); + } + } + } + + private synchronized void handleDependentDeleted( + DependentResourceNode dependentResourceNode) { + dependentResourceNode + .getDependsOn() + .forEach( + dr -> { + log.debug( + "Handle deleted for: {} with dependent: {} primaryID: {}", + dr, + dependentResourceNode, + primaryID); + handleDelete(dr); + }); + } + + private synchronized void handleDependentsReconcile( + DependentResourceNode dependentResourceNode) { + var dependents = dependentResourceNode.getParents(); + dependents.forEach( + d -> { + log.debug( + "Handle reconcile for dependent: {} of parent:{} primaryID: {}", + d, + dependentResourceNode, + primaryID); + handleReconcile(d); + }); + } + + private void handleReconcileOrActivationConditionNotMet( + DependentResourceNode dependentResourceNode, boolean activationConditionMet) { + Set bottomNodes = new HashSet<>(); + markDependentsForDelete(dependentResourceNode, bottomNodes, activationConditionMet); + bottomNodes.forEach(this::handleDelete); + } + + private void markDependentsForDelete( + DependentResourceNode dependentResourceNode, + Set bottomNodes, + boolean activationConditionMet) { + // this is a check so the activation condition is not evaluated twice, + // so if the activation condition was false, this node is not meant to be deleted. + var dependents = dependentResourceNode.getParents(); + if (activationConditionMet) { + createOrGetResultFor(dependentResourceNode).markForDelete(); + if (dependents.isEmpty()) { + bottomNodes.add(dependentResourceNode); + } else { + dependents.forEach(d -> markDependentsForDelete(d, bottomNodes, true)); + } + } else { + // this is for an edge case when there is only one resource but that is not active + createOrGetResultFor(dependentResourceNode).markAsVisited(); + if (dependents.isEmpty()) { + handleNodeExecutionFinish(dependentResourceNode); + } else { + dependents.forEach(d -> markDependentsForDelete(d, bottomNodes, true)); + } + } + } + + private boolean allParentsReconciledAndReady(DependentResourceNode dependentResourceNode) { + return dependentResourceNode.getDependsOn().isEmpty() + || dependentResourceNode.getDependsOn().stream() + .allMatch(d -> alreadyVisited(d) && isReady(d)); + } + + private boolean hasErroredParent(DependentResourceNode dependentResourceNode) { + return !dependentResourceNode.getDependsOn().isEmpty() + && dependentResourceNode.getDependsOn().stream().anyMatch(this::isInError); + } + + private WorkflowReconcileResult createReconcileResult() { + return new DefaultWorkflowReconcileResult(asDetails()); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileResult.java new file mode 100644 index 0000000000..939f112697 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileResult.java @@ -0,0 +1,28 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.List; +import java.util.Optional; + +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +@SuppressWarnings("rawtypes") +public interface WorkflowReconcileResult extends WorkflowResult { + WorkflowReconcileResult EMPTY = new WorkflowReconcileResult() {}; + + default List getReconciledDependents() { + return List.of(); + } + + default List getNotReadyDependents() { + return List.of(); + } + + default Optional getNotReadyDependentResult( + DependentResource dependentResource, Class expectedResultType) { + return Optional.empty(); + } + + default boolean allDependentResourcesReady() { + return getNotReadyDependents().isEmpty(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowResult.java new file mode 100644 index 0000000000..b9bbd45233 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowResult.java @@ -0,0 +1,72 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.Map; +import java.util.Optional; + +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +@SuppressWarnings("rawtypes") +public interface WorkflowResult { + default Map getErroredDependents() { + return Map.of(); + } + + /** + * Retrieves the {@link DependentResource} associated with the specified name if it exists, {@link + * Optional#empty()} otherwise. + * + * @param name the name of the {@link DependentResource} to retrieve + * @return the {@link DependentResource} associated with the specified name if it exists, {@link + * Optional#empty()} otherwise + */ + default Optional getDependentResourceByName(String name) { + return Optional.empty(); + } + + /** + * Retrieves the optional result of the condition with the specified type for the specified + * dependent resource. + * + * @param the expected result type of the condition + * @param dependentResourceName the dependent resource for which we want to retrieve a condition + * result + * @param conditionType the condition type which result we're interested in + * @param expectedResultType the expected result type of the condition + * @return the dependent condition result if it exists or {@link Optional#empty()} otherwise + * @throws IllegalArgumentException if a result exists but is not of the expected type + */ + default Optional getDependentConditionResult( + String dependentResourceName, Condition.Type conditionType, Class expectedResultType) { + return getDependentConditionResult( + getDependentResourceByName(dependentResourceName).orElse(null), + conditionType, + expectedResultType); + } + + /** + * Retrieves the optional result of the condition with the specified type for the specified + * dependent resource. + * + * @param the expected result type of the condition + * @param dependentResource the dependent resource for which we want to retrieve a condition + * result + * @param conditionType the condition type which result we're interested in + * @param expectedResultType the expected result type of the condition + * @return the dependent condition result if it exists or {@link Optional#empty()} otherwise + * @throws IllegalArgumentException if a result exists but is not of the expected type + */ + default Optional getDependentConditionResult( + DependentResource dependentResource, + Condition.Type conditionType, + Class expectedResultType) { + return Optional.empty(); + } + + default boolean erroredDependentsExist() { + return false; + } + + default void throwAggregateExceptionIfErrorsPresent() { + throw new UnsupportedOperationException("Implement this method"); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/Event.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/Event.java new file mode 100644 index 0000000000..9ed00625bb --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/Event.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.util.Objects; + +public class Event { + + private final ResourceID relatedCustomResource; + + public Event(ResourceID targetCustomResource) { + this.relatedCustomResource = targetCustomResource; + } + + public ResourceID getRelatedCustomResourceID() { + return relatedCustomResource; + } + + @Override + public String toString() { + return "Event{" + "relatedCustomResource=" + relatedCustomResource + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Event event = (Event) o; + return Objects.equals(relatedCustomResource, event.relatedCustomResource); + } + + @Override + public int hashCode() { + return Objects.hash(relatedCustomResource); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventHandler.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventHandler.java new file mode 100644 index 0000000000..064b566220 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventHandler.java @@ -0,0 +1,6 @@ +package io.javaoperatorsdk.operator.processing.event; + +public interface EventHandler { + + void handleEvent(Event event); +} 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 new file mode 100644 index 0000000000..e029e287a0 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java @@ -0,0 +1,512 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.net.HttpURLConnection; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutorService; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.processing.LifecycleAware; +import io.javaoperatorsdk.operator.processing.MDCUtils; +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.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); + private static final long MINIMAL_RATE_LIMIT_RESCHEDULE_DURATION = 50; + + private volatile boolean running; + private final ControllerConfiguration controllerConfiguration; + private final ReconciliationDispatcher

reconciliationDispatcher; + private final Retry retry; + private final Metrics metrics; + private final Cache

cache; + private final EventSourceManager

eventSourceManager; + private final RateLimiter rateLimiter; + private final ResourceStateManager resourceStateManager = new ResourceStateManager(); + private final Map metricsMetadata; + private ExecutorService executor; + + public EventProcessor( + EventSourceManager

eventSourceManager, ConfigurationService configurationService) { + this( + eventSourceManager.getController().getConfiguration(), + new ReconciliationDispatcher<>(eventSourceManager.getController()), + eventSourceManager, + configurationService.getMetrics(), + eventSourceManager.getControllerEventSource()); + } + + @SuppressWarnings("rawtypes") + EventProcessor( + ControllerConfiguration controllerConfiguration, + ReconciliationDispatcher

reconciliationDispatcher, + EventSourceManager

eventSourceManager, + Metrics metrics) { + this( + controllerConfiguration, + reconciliationDispatcher, + eventSourceManager, + metrics, + eventSourceManager.getControllerEventSource()); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private EventProcessor( + ControllerConfiguration controllerConfiguration, + ReconciliationDispatcher

reconciliationDispatcher, + EventSourceManager

eventSourceManager, + Metrics metrics, + Cache

cache) { + this.controllerConfiguration = controllerConfiguration; + this.running = false; + this.reconciliationDispatcher = reconciliationDispatcher; + this.retry = controllerConfiguration.getRetry(); + this.cache = cache; + this.metrics = metrics != null ? metrics : Metrics.NOOP; + this.eventSourceManager = eventSourceManager; + this.rateLimiter = controllerConfiguration.getRateLimiter(); + + metricsMetadata = + Optional.ofNullable(eventSourceManager.getController()) + .map( + c -> + Map.of( + Constants.RESOURCE_GVK_KEY, c.getAssociatedGroupVersionKind(), + Constants.CONTROLLER_NAME, controllerConfiguration.getName())) + .orElseGet(HashMap::new); + } + + @Override + public synchronized void handleEvent(Event event) { + try { + log.debug("Received event: {}", event); + + final var optionalState = resourceStateManager.getOrCreateOnResourceEvent(event); + if (optionalState.isEmpty()) { + log.debug( + "Skipping event, since no state present and it is not a resource event. Resource ID:" + + " {}", + event.getRelatedCustomResourceID()); + return; + } + var state = optionalState.orElseThrow(); + final var resourceID = event.getRelatedCustomResourceID(); + MDCUtils.addResourceIDInfo(resourceID); + metrics.receivedEvent(event, metricsMetadata); + handleEventMarking(event, state); + if (!this.running) { + if (state.deleteEventPresent()) { + cleanupForDeletedEvent(state.getId()); + } + // events are received and marked, but will be processed when started, see start() method. + log.debug("Skipping event: {} because the event processor is not started", event); + return; + } + handleMarkedEventForResource(state); + } finally { + MDCUtils.removeResourceIDInfo(); + } + } + + private void handleMarkedEventForResource(ResourceState state) { + if (state.deleteEventPresent()) { + cleanupForDeletedEvent(state.getId()); + } else if (!state.processedMarkForDeletionPresent()) { + submitReconciliationExecution(state); + } + } + + private void submitReconciliationExecution(ResourceState state) { + try { + boolean controllerUnderExecution = isControllerUnderExecution(state); + final var resourceID = state.getId(); + Optional

maybeLatest = cache.get(resourceID); + maybeLatest.ifPresent(MDCUtils::addResourceInfo); + if (!controllerUnderExecution && maybeLatest.isPresent()) { + var rateLimit = state.getRateLimit(); + if (rateLimit == null) { + rateLimit = rateLimiter.initState(); + state.setRateLimit(rateLimit); + } + var rateLimiterPermission = rateLimiter.isLimited(rateLimit); + if (rateLimiterPermission.isPresent()) { + handleRateLimitedSubmission(resourceID, rateLimiterPermission.get()); + return; + } + state.setUnderProcessing(true); + final var latest = maybeLatest.get(); + ExecutionScope

executionScope = new ExecutionScope<>(state.getRetry()); + state.unMarkEventReceived(); + metrics.reconcileCustomResource(latest, state.getRetry(), metricsMetadata); + log.debug("Executing events for custom resource. Scope: {}", executionScope); + executor.execute(new ReconcilerExecutor(resourceID, executionScope)); + } else { + log.debug( + "Skipping executing controller for resource id: {}. Controller in execution: {}. Latest" + + " Resource present: {}", + resourceID, + controllerUnderExecution, + maybeLatest.isPresent()); + if (maybeLatest.isEmpty()) { + // there can be multiple reasons why the primary resource is not present, one is that the + // informer is currently disconnected from k8s api server, but will eventually receive the + // 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); + } + } + } finally { + MDCUtils.removeResourceInfo(); + } + } + + 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); + state.markDeleteEventReceived(); + } else { + if (state.processedMarkForDeletionPresent() && isResourceMarkedForDeletion(resourceEvent)) { + log.debug( + "Skipping mark of event received, since already processed mark for deletion and" + + " resource marked for deletion: {}", + relatedCustomResourceID); + return; + } + // Normally when eventMarker is in state PROCESSED_MARK_FOR_DELETION it is expected to + // receive a Delete event or an event where resource is marked for deletion. In a rare edge + // case however it can happen that the finalizer related to the current controller is + // removed, but also the informers websocket is disconnected and later reconnected. So + // meanwhile the resource could be deleted and recreated. In this case we just mark a new + // event as below. + markEventReceived(state); + } + } else if (!state.deleteEventPresent() && !state.processedMarkForDeletionPresent()) { + markEventReceived(state); + } else if (log.isDebugEnabled()) { + log.debug( + "Skipped marking event as received. Delete event present: {}, processed mark for" + + " deletion: {}", + state.deleteEventPresent(), + state.processedMarkForDeletionPresent()); + } + } + + private void markEventReceived(ResourceState state) { + log.debug("Marking event received for: {}", state.getId()); + state.markEventReceived(); + } + + private boolean isResourceMarkedForDeletion(ResourceEvent resourceEvent) { + return resourceEvent.getResource().map(HasMetadata::isMarkedForDeletion).orElse(false); + } + + private void handleRateLimitedSubmission(ResourceID resourceID, Duration minimalDuration) { + var minimalDurationMillis = minimalDuration.toMillis(); + log.debug( + "Rate limited resource: {}, rescheduled in {} millis", resourceID, minimalDurationMillis); + retryEventSource() + .scheduleOnce( + resourceID, Math.max(minimalDurationMillis, MINIMAL_RATE_LIMIT_RESCHEDULE_DURATION)); + } + + synchronized void eventProcessingFinished( + ExecutionScope

executionScope, PostExecutionControl

postExecutionControl) { + if (!running) { + return; + } + ResourceID resourceID = executionScope.getResourceID(); + final var state = resourceStateManager.getOrCreate(resourceID); + log.debug( + "Event processing finished. Scope: {}, PostExecutionControl: {}", + executionScope, + postExecutionControl); + unsetUnderExecution(resourceID); + + logErrorIfNoRetryConfigured(executionScope, postExecutionControl); + // If a delete event present at this phase, it was received during reconciliation. + // So we either removed the finalizer during reconciliation or we don't use finalizers. + // Either way we don't want to retry. + if (isRetryConfigured() + && postExecutionControl.exceptionDuringExecution() + && !state.deleteEventPresent()) { + handleRetryOnException( + executionScope, postExecutionControl.getRuntimeException().orElseThrow()); + return; + } + cleanupOnSuccessfulExecution(executionScope); + metrics.finishedReconciliation(executionScope.getResource(), metricsMetadata); + if (state.deleteEventPresent()) { + cleanupForDeletedEvent(executionScope.getResourceID()); + } else if (postExecutionControl.isFinalizerRemoved()) { + state.markProcessedMarkForDeletion(); + metrics.cleanupDoneFor(resourceID, metricsMetadata); + } else { + if (state.eventPresent()) { + submitReconciliationExecution(state); + } else { + reScheduleExecutionIfInstructed(postExecutionControl, executionScope.getResource()); + } + } + } + + /** + * In case retry is configured more complex error logging takes place, see handleRetryOnException + */ + private void logErrorIfNoRetryConfigured( + ExecutionScope

executionScope, PostExecutionControl

postExecutionControl) { + if (!isRetryConfigured() && postExecutionControl.exceptionDuringExecution()) { + log.error( + "Error during event processing {}", + executionScope, + postExecutionControl.getRuntimeException().orElseThrow()); + } + } + + private void reScheduleExecutionIfInstructed( + PostExecutionControl

postExecutionControl, P customResource) { + + postExecutionControl + .getReScheduleDelay() + .ifPresentOrElse( + delay -> { + var resourceID = ResourceID.fromResource(customResource); + log.debug("Rescheduling event for resource: {} with delay: {}", resourceID, delay); + retryEventSource().scheduleOnce(resourceID, delay); + }, + () -> scheduleExecutionForMaxReconciliationInterval(customResource)); + } + + private void scheduleExecutionForMaxReconciliationInterval(P customResource) { + this.controllerConfiguration + .maxReconciliationInterval() + .ifPresent( + m -> { + var resourceID = ResourceID.fromResource(customResource); + var delay = m.toMillis(); + log.debug( + "Rescheduling event for max reconciliation interval for resource: {} : " + + "with delay: {}", + resourceID, + delay); + retryEventSource().scheduleOnce(resourceID, delay); + }); + } + + TimerEventSource

retryEventSource() { + return eventSourceManager.retryEventSource(); + } + + /** + * Regarding the events there are 2 approaches we can take. Either retry always when there are new + * events (received meanwhile retry is in place or already in buffer) instantly or always wait + * according to the retry timing if there was an exception. + */ + private void handleRetryOnException(ExecutionScope

executionScope, Exception exception) { + final var state = getOrInitRetryExecution(executionScope); + var resourceID = state.getId(); + boolean eventPresent = state.eventPresent(); + state.markEventReceived(); + + retryAwareErrorLogging(state.getRetry(), eventPresent, exception, executionScope); + if (eventPresent) { + log.debug("New events exists for for resource id: {}", resourceID); + submitReconciliationExecution(state); + return; + } + Optional nextDelay = state.getRetry().nextDelay(); + + nextDelay.ifPresentOrElse( + delay -> { + log.debug( + "Scheduling timer event for retry with delay:{} for resource: {}", delay, resourceID); + metrics.failedReconciliation(executionScope.getResource(), exception, metricsMetadata); + retryEventSource().scheduleOnce(resourceID, delay); + }, + () -> { + log.error("Exhausted retries for scope {}.", executionScope); + scheduleExecutionForMaxReconciliationInterval(executionScope.getResource()); + }); + } + + private void retryAwareErrorLogging( + RetryExecution retry, + boolean eventPresent, + Exception exception, + ExecutionScope

executionScope) { + if (!retry.isLastAttempt() + && exception instanceof KubernetesClientException ex + && ex.getCode() == HttpURLConnection.HTTP_CONFLICT) { + log.debug("Full client conflict error during event processing {}", executionScope, exception); + log.info( + "Resource Kubernetes Resource Creator/Update Conflict during reconciliation. Message:" + + " {} Resource name: {}", + ex.getMessage(), + ex.getFullResourceName()); + } else if (eventPresent || !retry.isLastAttempt()) { + log.warn( + "Uncaught error during event processing {} - but another reconciliation will be attempted" + + " because a superseding event has been received or another retry attempt is" + + " pending.", + executionScope, + exception); + } else { + log.error( + "Uncaught error during event processing {} - no superseding event is present and this is" + + " the retry last attempt", + executionScope, + exception); + } + } + + private void cleanupOnSuccessfulExecution(ExecutionScope

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

executionScope) { + final var state = resourceStateManager.getOrCreate(executionScope.getResourceID()); + RetryExecution retryExecution = state.getRetry(); + if (retryExecution == null) { + retryExecution = retry.initExecution(); + state.setRetry(retryExecution); + } + return state; + } + + private void cleanupForDeletedEvent(ResourceID resourceID) { + log.debug("Cleaning up for delete event for: {}", resourceID); + resourceStateManager.remove(resourceID); + metrics.cleanupDoneFor(resourceID, metricsMetadata); + } + + private boolean isControllerUnderExecution(ResourceState state) { + return state.isUnderProcessing(); + } + + private void unsetUnderExecution(ResourceID resourceID) { + resourceStateManager.getOrCreate(resourceID).setUnderProcessing(false); + } + + private boolean isRetryConfigured() { + return retry != null; + } + + @Override + public synchronized void stop() { + this.running = false; + } + + @Override + public synchronized void start() throws OperatorException { + log.debug("Starting event processor: {}", this); + // on restart new executor service is created and needs to be set here + executor = + controllerConfiguration + .getConfigurationService() + .getExecutorServiceManager() + .reconcileExecutorService(); + this.running = true; + handleAlreadyMarkedEvents(); + } + + public boolean isNextReconciliationImminent(ResourceID resourceID) { + return resourceStateManager.getOrCreate(resourceID).eventPresent(); + } + + private void handleAlreadyMarkedEvents() { + for (var state : resourceStateManager.resourcesWithEventPresent()) { + log.debug("Handling already marked event on start. State: {}", state); + handleMarkedEventForResource(state); + } + } + + private class ReconcilerExecutor implements Runnable { + private final ExecutionScope

executionScope; + private final ResourceID resourceID; + + private ReconcilerExecutor(ResourceID resourceID, ExecutionScope

executionScope) { + this.executionScope = executionScope; + this.resourceID = resourceID; + } + + @Override + public void run() { + if (!running) { + // this is needed for the case when controller stopped, but there is a graceful shutdown + // timeout. that should finish the currently executing reconciliations but not the ones + // which where submitted but not started yet + log.debug("Event processor not running skipping resource processing: {}", resourceID); + return; + } + // change thread name for easier debugging + final var thread = Thread.currentThread(); + final var name = thread.getName(); + try { + var actualResource = cache.get(resourceID); + if (actualResource.isEmpty()) { + log.debug("Skipping execution; primary resource missing from cache: {}", resourceID); + return; + } + actualResource.ifPresent(executionScope::setResource); + MDCUtils.addResourceInfo(executionScope.getResource()); + metrics.reconciliationExecutionStarted(executionScope.getResource(), metricsMetadata); + thread.setName("ReconcilerExecutor-" + controllerName() + "-" + thread.getId()); + PostExecutionControl

postExecutionControl = + reconciliationDispatcher.handleExecution(executionScope); + eventProcessingFinished(executionScope, postExecutionControl); + } finally { + metrics.reconciliationExecutionFinished(executionScope.getResource(), metricsMetadata); + // restore original name + thread.setName(name); + MDCUtils.removeResourceInfo(); + } + } + + @Override + public String toString() { + return controllerName() + + " -> " + + (executionScope.getResource() != null ? executionScope : resourceID); + } + } + + private String controllerName() { + return controllerConfiguration.getName(); + } + + public synchronized boolean isUnderProcessing(ResourceID resourceID) { + return isControllerUnderExecution(resourceStateManager.getOrCreate(resourceID)); + } + + public synchronized boolean isRunning() { + return running; + } +} 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 new file mode 100644 index 0000000000..8b07bf110b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java @@ -0,0 +1,265 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.MissingCRDException; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.config.ExecutorServiceManager; +import io.javaoperatorsdk.operator.api.config.NamespaceChangeable; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.processing.Controller; +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.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; + +public class EventSourceManager

+ implements LifecycleAware, EventSourceRetriever

{ + + private static final Logger log = LoggerFactory.getLogger(EventSourceManager.class); + + private final EventSources

eventSources; + private final Controller

controller; + private final ExecutorServiceManager executorServiceManager; + + public EventSourceManager(Controller

controller) { + this(controller, new EventSources<>()); + } + + EventSourceManager(Controller

controller, EventSources

eventSources) { + this.eventSources = eventSources; + this.controller = controller; + this.executorServiceManager = controller.getExecutorServiceManager(); + // controller event source needs to be available before we create the event processor + eventSources.createControllerEventSource(controller); + postProcessDefaultEventSourcesAfterProcessorInitializer(); + } + + public void postProcessDefaultEventSourcesAfterProcessorInitializer() { + eventSources.controllerEventSource().setEventHandler(controller.getEventProcessor()); + eventSources.retryEventSource().setEventHandler(controller.getEventProcessor()); + } + + /** + * Starts the event sources first and then the processor. Note that it's not desired to start + * processing events while the event sources are not "synced". This not fully started and the + * caches propagated - although for non k8s related event sources this behavior might be different + * (see {@link + * io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingEventSource}). + * + *

Now the event sources are also started sequentially, mainly because others might depend on + * {@link ControllerEventSource} , which is started first. + */ + @Override + public synchronized void start() { + startEventSource(eventSources.controllerEventSource()); + + executorServiceManager.boundedExecuteAndWaitForAllToComplete( + eventSources + .additionalEventSources() + .filter(es -> es.priority().equals(EventSourceStartPriority.RESOURCE_STATE_LOADER)), + this::startEventSource, + getThreadNamer("start")); + + executorServiceManager.boundedExecuteAndWaitForAllToComplete( + eventSources + .additionalEventSources() + .filter(es -> es.priority().equals(EventSourceStartPriority.DEFAULT)), + this::startEventSource, + getThreadNamer("start")); + } + + @SuppressWarnings("rawtypes") + private static Function getThreadNamer(String stage) { + return es -> es.priority() + " " + stage + " -> " + es.name(); + } + + private static Function getEventSourceThreadNamer(String stage) { + return es -> stage + " -> " + es; + } + + @Override + public synchronized void stop() { + stopEventSource(eventSources.controllerEventSource()); + executorServiceManager.boundedExecuteAndWaitForAllToComplete( + eventSources.additionalEventSources(), this::stopEventSource, getThreadNamer("stop")); + } + + @SuppressWarnings("rawtypes") + private void logEventSourceEvent(EventSource eventSource, String event) { + if (log.isDebugEnabled()) { + log.debug("{} event source {} for {}", event, eventSource.name(), eventSource.resourceType()); + } + } + + private Void startEventSource(EventSource eventSource) { + try { + logEventSourceEvent(eventSource, "Starting"); + eventSource.start(); + logEventSourceEvent(eventSource, "Started"); + } catch (MissingCRDException e) { + throw e; // leave untouched + } catch (Exception e) { + throw new OperatorException("Couldn't start source " + eventSource.name(), e); + } + return null; + } + + private Void stopEventSource(EventSource eventSource) { + try { + logEventSourceEvent(eventSource, "Stopping"); + eventSource.stop(); + logEventSourceEvent(eventSource, "Stopped"); + } catch (Exception e) { + log.warn("Error closing {} -> {}", eventSource.name(), e); + } + return null; + } + + @SuppressWarnings("rawtypes") + public final synchronized void registerEventSource(EventSource eventSource) + throws OperatorException { + Objects.requireNonNull(eventSource, "EventSource must not be null"); + try { + if (eventSource instanceof ManagedInformerEventSource managedInformerEventSource) { + managedInformerEventSource.setControllerConfiguration(controller.getConfiguration()); + } + eventSources.add(eventSource); + eventSource.setEventHandler(controller.getEventProcessor()); + } catch (IllegalStateException | MissingCRDException e) { + throw e; // leave untouched + } catch (Exception e) { + throw new OperatorException( + "Couldn't register event source: " + + eventSource.name() + + " for " + + controller.getConfiguration().getName() + + " controller", + e); + } + } + + @SuppressWarnings("unchecked") + public void broadcastOnResourceEvent(ResourceAction action, P resource, P oldResource) { + eventSources + .additionalEventSources() + .forEach( + source -> { + if (source instanceof ResourceEventAware) { + var lifecycleAwareES = ((ResourceEventAware

) source); + switch (action) { + case ADDED: + lifecycleAwareES.onResourceCreated(resource); + break; + case UPDATED: + lifecycleAwareES.onResourceUpdated(resource, oldResource); + break; + case DELETED: + lifecycleAwareES.onResourceDeleted(resource); + break; + } + } + }); + } + + public void changeNamespaces(Set namespaces) { + eventSources.controllerEventSource().changeNamespaces(namespaces); + final var namespaceChangeables = + eventSources + .additionalEventSources() + .filter(NamespaceChangeable.class::isInstance) + .map(NamespaceChangeable.class::cast) + .filter(NamespaceChangeable::allowsNamespaceChanges); + executorServiceManager.boundedExecuteAndWaitForAllToComplete( + namespaceChangeables, + e -> { + e.changeNamespaces(namespaces); + return null; + }, + getEventSourceThreadNamer("changeNamespace")); + } + + public Set> getRegisteredEventSources() { + return eventSources.flatMappedSources().collect(Collectors.toCollection(LinkedHashSet::new)); + } + + @SuppressWarnings("rawtypes") + public List allEventSources() { + return eventSources.allEventSources().toList(); + } + + @SuppressWarnings("unused") + public Stream> getEventSourcesStream() { + return eventSources.flatMappedSources(); + } + + @Override + public ControllerEventSource

getControllerEventSource() { + return eventSources.controllerEventSource(); + } + + public List> getEventSourcesFor(Class dependentType) { + return eventSources.getEventSources(dependentType); + } + + @Override + public EventSource dynamicallyRegisterEventSource(EventSource eventSource) { + synchronized (this) { + var actual = eventSources.existingEventSourceByName(eventSource.name()); + if (actual != null) { + eventSource = actual; + } else { + registerEventSource(eventSource); + } + } + // The start itself is blocking thus blocking only the threads which are attempt to start the + // actual event source. Think of this as a form of lock striping. + eventSource.start(); + return eventSource; + } + + @Override + public synchronized Optional> dynamicallyDeRegisterEventSource( + String name) { + @SuppressWarnings("unchecked") + EventSource es = eventSources.remove(name); + if (es != null) { + es.stop(); + } + return Optional.ofNullable(es); + } + + @Override + public EventSourceContext

eventSourceContextForDynamicRegistration() { + return controller.eventSourceContext(); + } + + @Override + public EventSource getEventSourceFor(Class dependentType, String name) { + Objects.requireNonNull(dependentType, "dependentType is Mandatory"); + return eventSources.get(dependentType, name); + } + + TimerEventSource

retryEventSource() { + return eventSources.retryEventSource(); + } + + Controller

getController() { + return controller; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceRetriever.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceRetriever.java new file mode 100644 index 0000000000..c5a219a026 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceRetriever.java @@ -0,0 +1,69 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.util.List; +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; + +public interface EventSourceRetriever

{ + + default EventSource getEventSourceFor(Class dependentType) { + return getEventSourceFor(dependentType, null); + } + + EventSource getEventSourceFor(Class dependentType, String name); + + List> getEventSourcesFor(Class dependentType); + + ControllerEventSource

getControllerEventSource(); + + /** + * Registers (and starts) the specified {@link EventSource} dynamically during the reconciliation. + * If an EventSource is already registered with the specified name, the registration will be + * ignored. It is the user's responsibility to handle the naming correctly. + * + *

This is only needed when your operator needs to adapt dynamically based on optional + * resources that may or may not be present on the target cluster. Even in this situation, it + * should be possible to make these decisions at when the "regular" EventSources are registered so + * this method should not typically be called directly but rather by the framework to support + * activation conditions of dependents, for example. + * + *

This method will block until the event source is synced (if needed, as it is the case for + * {@link io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource}). + * + *

IMPORTANT: Should multiple reconciliations happen concurrently, only one + * EventSource with the specified name will ever be registered. It is therefore important to + * explicitly name the event sources that you want to reuse because the name will be used to + * identify which event sources need to be created or not. If you let JOSDK implicitly name event + * sources, then you might end up with duplicated event sources because concurrent registration of + * event sources will lead to 2 (or more) event sources for the same resource type to be attempted + * to be registered under different, automatically generated names. If you clearly identify your + * event sources with names, then, if the concurrent process determines that an event source with + * the specified name, it won't register it again. + * + * @param eventSource to register + * @return the actual event source registered. Might not be the same as the parameter. + */ + EventSource dynamicallyRegisterEventSource(EventSource eventSource); + + /** + * De-registers (and stops) the {@link EventSource} associated with the specified name. If no such + * source exists, this method will do nothing. + * + *

This method will block until the event source is de-registered and stopped. If multiple + * reconciliations happen concurrently, all will be blocked until the event source is + * de-registered. + * + *

This method is meant only to be used for dynamically registered event sources and should not + * be typically called directly. + * + * @param name of the event source + * @return the actual event source deregistered if there is one. + */ + Optional> dynamicallyDeRegisterEventSource(String name); + + EventSourceContext

eventSourceContextForDynamicRegistration(); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSources.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSources.java new file mode 100644 index 0000000000..6a8b290c4f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSources.java @@ -0,0 +1,159 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentNavigableMap; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.stream.Stream; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.timer.TimerEventSource; + +class EventSources

{ + + private final ConcurrentNavigableMap>> sources = + new ConcurrentSkipListMap<>(); + private final Map sourceByName = new HashMap<>(); + + private final TimerEventSource

retryAndRescheduleTimerEventSource = + new TimerEventSource<>("RetryAndRescheduleTimerEventSource"); + private ControllerEventSource

controllerEventSource; + + public void add(EventSource eventSource) { + final var name = eventSource.name(); + var existing = sourceByName.get(name); + if (existing != null) { + throw new IllegalArgumentException( + "Event source " + existing + " is already registered with name: " + name); + } + sourceByName.put(name, eventSource); + sources + .computeIfAbsent(keyFor(eventSource), k -> new ConcurrentHashMap<>()) + .put(name, eventSource); + } + + public EventSource remove(String name) { + var optionalMap = sources.values().stream().filter(m -> m.containsKey(name)).findFirst(); + sourceByName.remove(name); + return optionalMap.map(m -> m.remove(name)).orElse(null); + } + + public void clear() { + sources.clear(); + sourceByName.clear(); + } + + public EventSource existingEventSourceByName(String name) { + return sourceByName.get(name); + } + + void createControllerEventSource(Controller

controller) { + controllerEventSource = new ControllerEventSource<>(controller); + } + + public ControllerEventSource

controllerEventSource() { + return controllerEventSource; + } + + TimerEventSource

retryEventSource() { + return retryAndRescheduleTimerEventSource; + } + + @SuppressWarnings("rawtypes") + public Stream allEventSources() { + return Stream.concat( + Stream.of(controllerEventSource(), retryAndRescheduleTimerEventSource), + flatMappedSources()); + } + + @SuppressWarnings("rawtypes") + Stream additionalEventSources() { + return Stream.concat( + Stream.of(retryEventSource()).filter(Objects::nonNull), flatMappedSources()); + } + + Stream> flatMappedSources() { + return sources.values().stream().flatMap(c -> c.values().stream()); + } + + private String keyFor(EventSource source) { + return keyFor(source.resourceType()); + } + + private String keyFor(Class dependentType) { + return dependentType.getCanonicalName(); + } + + @SuppressWarnings("unchecked") + public EventSource get(Class dependentType, String name) { + if (dependentType == null) { + throw new IllegalArgumentException("Must pass a dependent type to retrieve event sources"); + } + + final var sourcesForType = sources.get(keyFor(dependentType)); + if (sourcesForType == null || sourcesForType.isEmpty()) { + throw new NoEventSourceForClassException(dependentType); + } + + final var size = sourcesForType.size(); + EventSource source; + if (size == 1 && name == null) { + source = (EventSource) sourcesForType.values().stream().findFirst().orElseThrow(); + } else { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException( + "There are multiple EventSources registered for type " + + dependentType.getCanonicalName() + + ", you need to provide a name to specify which EventSource you want to query." + + " Known names: " + + String.join(",", sourcesForType.keySet())); + } + source = (EventSource) sourcesForType.get(name); + + if (source == null) { + throw new IllegalArgumentException( + "There is no event source found for class:" + + " " + + dependentType.getName() + + ", name:" + + name); + } + } + + final var resourceClass = source.resourceType(); + if (!resourceClass.isAssignableFrom(dependentType)) { + throw new IllegalArgumentException( + source + + " associated with " + + keyAsString(dependentType, name) + + " is handling " + + resourceClass.getName() + + " resources but asked for " + + dependentType.getName()); + } + return source; + } + + @SuppressWarnings("rawtypes") + private String keyAsString(Class dependentType, String name) { + return name != null && !name.isEmpty() + ? "(" + dependentType.getName() + ", " + name + ")" + : dependentType.getName(); + } + + @SuppressWarnings("unchecked") + public List> getEventSources(Class dependentType) { + final var sourcesForType = sources.get(keyFor(dependentType)); + if (sourcesForType == null) { + return Collections.emptyList(); + } + return sourcesForType.values().stream().map(es -> (EventSource) es).toList(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ExecutionScope.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ExecutionScope.java new file mode 100644 index 0000000000..90899a6e1a --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ExecutionScope.java @@ -0,0 +1,45 @@ +package io.javaoperatorsdk.operator.processing.event; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; + +class ExecutionScope { + + // the latest custom resource from cache + private R resource; + private final RetryInfo retryInfo; + + ExecutionScope(RetryInfo retryInfo) { + this.retryInfo = retryInfo; + } + + public ExecutionScope setResource(R resource) { + this.resource = resource; + return this; + } + + public R getResource() { + return resource; + } + + public ResourceID getResourceID() { + return ResourceID.fromResource(resource); + } + + @Override + public String toString() { + if (resource == null) { + return "ExecutionScope{resource: null}"; + } else + return "ExecutionScope{" + + " resource id: " + + ResourceID.fromResource(resource) + + ", version: " + + resource.getMetadata().getResourceVersion() + + '}'; + } + + public RetryInfo getRetryInfo() { + return retryInfo; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/NoEventSourceForClassException.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/NoEventSourceForClassException.java new file mode 100644 index 0000000000..74c3449f87 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/NoEventSourceForClassException.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.processing.event; + +import io.javaoperatorsdk.operator.OperatorException; + +public class NoEventSourceForClassException extends OperatorException { + + private Class clazz; + + public NoEventSourceForClassException(Class clazz) { + this.clazz = clazz; + } + + public Class getClazz() { + return clazz; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/PostExecutionControl.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/PostExecutionControl.java new file mode 100644 index 0000000000..42311c1cb5 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/PostExecutionControl.java @@ -0,0 +1,96 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +final class PostExecutionControl { + + private final boolean finalizerRemoved; + private final R updatedCustomResource; + private final boolean updateIsStatusPatch; + private final Exception runtimeException; + + private Long reScheduleDelay = null; + + private PostExecutionControl( + boolean finalizerRemoved, + R updatedCustomResource, + boolean updateIsStatusPatch, + Exception runtimeException) { + this.finalizerRemoved = finalizerRemoved; + this.updatedCustomResource = updatedCustomResource; + this.updateIsStatusPatch = updateIsStatusPatch; + this.runtimeException = runtimeException; + } + + public static PostExecutionControl onlyFinalizerAdded( + R updatedCustomResource) { + return new PostExecutionControl<>(false, updatedCustomResource, false, null); + } + + public static PostExecutionControl defaultDispatch() { + return new PostExecutionControl<>(false, null, false, null); + } + + public static PostExecutionControl customResourceStatusPatched( + R updatedCustomResource) { + return new PostExecutionControl<>(false, updatedCustomResource, true, null); + } + + public static PostExecutionControl customResourcePatched( + R updatedCustomResource) { + return new PostExecutionControl<>(false, updatedCustomResource, false, null); + } + + public static PostExecutionControl customResourceFinalizerRemoved( + R updatedCustomResource) { + return new PostExecutionControl<>(true, updatedCustomResource, false, null); + } + + public static PostExecutionControl exceptionDuringExecution( + Exception exception) { + return new PostExecutionControl<>(false, null, false, exception); + } + + public Optional getUpdatedCustomResource() { + return Optional.ofNullable(updatedCustomResource); + } + + public boolean exceptionDuringExecution() { + return runtimeException != null; + } + + public PostExecutionControl withReSchedule(long delay) { + this.reScheduleDelay = delay; + return this; + } + + public Optional getRuntimeException() { + return Optional.ofNullable(runtimeException); + } + + public Optional getReScheduleDelay() { + return Optional.ofNullable(reScheduleDelay); + } + + public boolean updateIsStatusPatch() { + return updateIsStatusPatch; + } + + @Override + public String toString() { + return "PostExecutionControl{" + + "onlyFinalizerHandled=" + + finalizerRemoved + + ", updatedCustomResource=" + + updatedCustomResource + + ", runtimeException=" + + runtimeException + + '}'; + } + + public boolean isFinalizerRemoved() { + return finalizerRemoved; + } +} 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 new file mode 100644 index 0000000000..90bdc93979 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java @@ -0,0 +1,543 @@ +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.api.config.Cloner; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.BaseControl; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.Controller; + +import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.*; + +/** 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; + private final CustomResourceFacade

customResourceFacade; + // this is to handle corner case, when there is a retry, but it is actually limited to 0. + // Usually for testing purposes. + private final boolean retryConfigurationHasZeroAttempts; + private final Cloner cloner; + private final boolean useSSA; + + ReconciliationDispatcher(Controller

controller, CustomResourceFacade

customResourceFacade) { + this.controller = controller; + this.customResourceFacade = customResourceFacade; + final var configuration = controller.getConfiguration(); + this.cloner = configuration.getConfigurationService().getResourceCloner(); + + var retry = configuration.getRetry(); + retryConfigurationHasZeroAttempts = retry == null || retry.initExecution().isLastAttempt(); + useSSA = configuration.getConfigurationService().useSSAToPatchPrimaryResource(); + } + + public ReconciliationDispatcher(Controller

controller) { + this( + controller, + new CustomResourceFacade<>( + controller.getCRClient(), + controller.getConfiguration(), + controller.getConfiguration().getConfigurationService().getResourceCloner())); + } + + public PostExecutionControl

handleExecution(ExecutionScope

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

handleDispatch(ExecutionScope

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

context = + new DefaultContext<>(executionScope.getRetryInfo(), controller, resourceForExecution); + if (markedForDeletion) { + return handleCleanup(resourceForExecution, originalResource, context); + } else { + return handleReconcile(executionScope, resourceForExecution, originalResource, context); + } + } + + private boolean shouldNotDispatchToCleanupWhenMarkedForDeletion(P resource) { + var alreadyRemovedFinalizer = + controller.useFinalizer() && !resource.hasFinalizer(configuration().getFinalizerName()); + return !controller.useFinalizer() || alreadyRemovedFinalizer; + } + + private PostExecutionControl

handleReconcile( + ExecutionScope

executionScope, + P resourceForExecution, + P originalResource, + Context

context) + throws Exception { + if (controller.useFinalizer() + && !originalResource.hasFinalizer(configuration().getFinalizerName())) { + /* + * We always add the finalizer if missing and the controller is configured to use a finalizer. + * We execute the controller processing only for processing the event sent as a results of the + * finalizer add. This will make sure that the resources are not created before there is a + * finalizer. + */ + P updatedResource; + if (useSSA) { + updatedResource = addFinalizerWithSSA(originalResource); + } else { + updatedResource = updateCustomResourceWithFinalizer(resourceForExecution, originalResource); + } + return PostExecutionControl.onlyFinalizerAdded(updatedResource); + } else { + try { + return reconcileExecution(executionScope, resourceForExecution, originalResource, context); + } catch (Exception e) { + return handleErrorStatusHandler(resourceForExecution, originalResource, context, e); + } + } + } + + private P cloneResource(P resource) { + return cloner.clone(resource); + } + + private PostExecutionControl

reconcileExecution( + ExecutionScope

executionScope, + P resourceForExecution, + P originalResource, + Context

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

updateControl = controller.reconcile(resourceForExecution, context); + + final P toUpdate; + P updatedCustomResource = null; + if (useSSA) { + if (updateControl.isNoUpdate()) { + return createPostExecutionControl(null, updateControl); + } else { + toUpdate = updateControl.getResource().orElseThrow(); + } + } else { + toUpdate = + updateControl.isNoUpdate() ? originalResource : updateControl.getResource().orElseThrow(); + } + + if (updateControl.isPatchResource()) { + updatedCustomResource = patchResource(toUpdate, originalResource); + if (!useSSA) { + toUpdate + .getMetadata() + .setResourceVersion(updatedCustomResource.getMetadata().getResourceVersion()); + } + } + + if (updateControl.isPatchStatus()) { + customResourceFacade.patchStatus(toUpdate, originalResource); + } + return createPostExecutionControl(updatedCustomResource, updateControl); + } + + private PostExecutionControl

handleErrorStatusHandler( + P resource, P originalResource, Context

context, Exception e) throws Exception { + RetryInfo retryInfo = + context + .getRetryInfo() + .orElseGet( + () -> + new RetryInfo() { + @Override + public int getAttemptCount() { + return 0; + } + + @Override + public boolean isLastAttempt() { + // check also if the retry is limited to 0 + return retryConfigurationHasZeroAttempts + || controller.getConfiguration().getRetry() == null; + } + }); + ((DefaultContext

) context).setRetryInfo(retryInfo); + var errorStatusUpdateControl = + controller.getReconciler().updateErrorStatus(resource, context, e); + + if (errorStatusUpdateControl.isDefaultErrorProcessing()) { + throw e; + } + + P updatedResource = null; + if (errorStatusUpdateControl.getResource().isPresent()) { + try { + updatedResource = + customResourceFacade.patchStatus( + errorStatusUpdateControl.getResource().orElseThrow(), originalResource); + } catch (Exception ex) { + int code = ex instanceof KubernetesClientException kcex ? kcex.getCode() : -1; + Level exceptionLevel = Level.ERROR; + String failedMessage = ""; + if (context.isNextReconciliationImminent() + || !(errorStatusUpdateControl.isNoRetry() || retryInfo.isLastAttempt())) { + if (code == HttpURLConnection.HTTP_CONFLICT + || (originalResource.getMetadata().getResourceVersion() != null && code == 422)) { + 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)); + } else { + exceptionLevel = Level.WARN; + failedMessage = ", but will be retried soon,"; + } + } + + log.atLevel(exceptionLevel) + .log( + "ErrorStatusUpdateControl.patchStatus failed{} for {} with UID: {} and version: {}" + + " for error {}", + failedMessage, + ResourceID.fromResource(originalResource), + getUID(resource), + getVersion(resource), + e.getMessage(), + ex); + } + } + if (errorStatusUpdateControl.isNoRetry()) { + PostExecutionControl

postExecutionControl; + if (updatedResource != null) { + postExecutionControl = PostExecutionControl.customResourceStatusPatched(updatedResource); + } else { + postExecutionControl = PostExecutionControl.defaultDispatch(); + } + errorStatusUpdateControl.getScheduleDelay().ifPresent(postExecutionControl::withReSchedule); + return postExecutionControl; + } + throw e; + } + + private PostExecutionControl

createPostExecutionControl( + P updatedCustomResource, UpdateControl

updateControl) { + PostExecutionControl

postExecutionControl; + if (updatedCustomResource != null) { + postExecutionControl = + PostExecutionControl.customResourceStatusPatched(updatedCustomResource); + } else { + postExecutionControl = PostExecutionControl.defaultDispatch(); + } + updatePostExecutionControlWithReschedule(postExecutionControl, updateControl); + return postExecutionControl; + } + + private void updatePostExecutionControlWithReschedule( + PostExecutionControl

postExecutionControl, BaseControl baseControl) { + baseControl.getScheduleDelay().ifPresent(postExecutionControl::withReSchedule); + } + + private PostExecutionControl

handleCleanup( + P resourceForExecution, P originalResource, Context

context) { + if (log.isDebugEnabled()) { + log.debug( + "Executing delete for resource: {} with version: {}", + ResourceID.fromResource(resourceForExecution), + getVersion(resourceForExecution)); + } + DeleteControl deleteControl = controller.cleanup(resourceForExecution, context); + final var useFinalizer = controller.useFinalizer(); + if (useFinalizer) { + // note that we don't reschedule here even if instructed. Removing finalizer means that + // 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); + return PostExecutionControl.customResourceFinalizerRemoved(customResource); + } + } + log.debug( + "Skipping finalizer remove for resource: {} with version: {}. delete control: {}, uses" + + " finalizer: {}", + getUID(resourceForExecution), + getVersion(resourceForExecution), + deleteControl, + useFinalizer); + PostExecutionControl

postExecutionControl = PostExecutionControl.defaultDispatch(); + updatePostExecutionControlWithReschedule(postExecutionControl, deleteControl); + 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 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(); + if (useSSA && controller.useFinalizer()) { + // addFinalizer already prevents adding an already present finalizer so no need to check + resource.addFinalizer(finalizerName); + } + return customResourceFacade.patchResource(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()); + } + } + } + + // 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; + 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) { + if (log.isDebugEnabled()) { + log.debug( + "Trying to replace resource {}, version: {}", + ResourceID.fromResource(resource), + resource.getMetadata().getResourceVersion()); + } + if (useSSA) { + return patchResourceWithSSA(resource); + } else { + return resource(originalResource).edit(r -> resource); + } + } + + public R patchStatus(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()); + } finally { + resource.getMetadata().setManagedFields(managedFields); + } + } else { + return editStatus(resource, originalResource); + } + } + + private R editStatus(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. + R clonedOriginal = cloner.clone(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; + }); + } 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); + } + } +} 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 new file mode 100644 index 0000000000..5354cad09e --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java @@ -0,0 +1,73 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.io.Serializable; +import java.util.Objects; +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.OwnerReference; + +public class ResourceID implements Serializable { + + public static ResourceID fromResource(HasMetadata resource) { + return new ResourceID(resource.getMetadata().getName(), resource.getMetadata().getNamespace()); + } + + public static ResourceID fromOwnerReference( + HasMetadata resource, OwnerReference ownerReference, boolean clusterScoped) { + return new ResourceID( + ownerReference.getName(), clusterScoped ? null : resource.getMetadata().getNamespace()); + } + + private final String name; + private final String namespace; + + public ResourceID(String name, String namespace) { + this.name = name; + this.namespace = namespace; + } + + public ResourceID(String name) { + this(name, null); + } + + public String getName() { + return name; + } + + public Optional getNamespace() { + return Optional.ofNullable(namespace); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ResourceID that = (ResourceID) o; + return Objects.equals(name, that.name) && Objects.equals(namespace, that.namespace); + } + + public boolean isSameResource(HasMetadata hasMetadata) { + final var metadata = hasMetadata.getMetadata(); + return getName().equals(metadata.getName()) + && getNamespace().map(ns -> ns.equals(metadata.getNamespace())).orElse(true); + } + + @Override + public int hashCode() { + return Objects.hash(name, namespace); + } + + @Override + public String toString() { + return toString(name, namespace); + } + + public static String toString(HasMetadata resource) { + return toString(resource.getMetadata().getName(), resource.getMetadata().getNamespace()); + } + + private static String toString(String name, String namespace) { + return "ResourceID{" + "name='" + name + '\'' + ", namespace='" + namespace + '\'' + '}'; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceState.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceState.java new file mode 100644 index 0000000000..5d4e74d681 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceState.java @@ -0,0 +1,127 @@ +package io.javaoperatorsdk.operator.processing.event; + +import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter.RateLimitState; +import io.javaoperatorsdk.operator.processing.retry.RetryExecution; + +class ResourceState { + + /** + * Manages the state of received events. Basically there can be only three distinct states + * relevant for event processing. Either an event is received, so we eventually process or no + * event for processing at the moment. The third case is if a DELETE event is received, this is a + * special case meaning that the custom resource is deleted. We don't want to do any processing + * anymore so other events are irrelevant for us from this point. Note that the dependant + * resources are either cleaned up by K8S garbage collection or by the controller implementation + * for cleanup. + */ + private enum EventingState { + EVENT_PRESENT, + NO_EVENT_PRESENT, + /** Resource has been marked for deletion, and cleanup already executed successfully */ + PROCESSED_MARK_FOR_DELETION, + /** Delete event present, from this point other events are not relevant */ + DELETE_EVENT_PRESENT, + } + + private final ResourceID id; + + private boolean underProcessing; + private RetryExecution retry; + private EventingState eventing; + private RateLimitState rateLimit; + + public ResourceState(ResourceID id) { + this.id = id; + eventing = EventingState.NO_EVENT_PRESENT; + } + + public ResourceID getId() { + return id; + } + + public RateLimitState getRateLimit() { + return rateLimit; + } + + public void setRateLimit(RateLimitState rateLimit) { + this.rateLimit = rateLimit; + } + + public RetryExecution getRetry() { + return retry; + } + + public void setRetry(RetryExecution retry) { + this.retry = retry; + } + + public boolean isUnderProcessing() { + return underProcessing; + } + + public void setUnderProcessing(boolean underProcessing) { + this.underProcessing = underProcessing; + } + + public void markDeleteEventReceived() { + eventing = EventingState.DELETE_EVENT_PRESENT; + } + + public boolean deleteEventPresent() { + return eventing == EventingState.DELETE_EVENT_PRESENT; + } + + public boolean processedMarkForDeletionPresent() { + return eventing == EventingState.PROCESSED_MARK_FOR_DELETION; + } + + public void markEventReceived() { + if (deleteEventPresent()) { + throw new IllegalStateException("Cannot receive event after a delete event received"); + } + eventing = EventingState.EVENT_PRESENT; + } + + public void markProcessedMarkForDeletion() { + eventing = EventingState.PROCESSED_MARK_FOR_DELETION; + } + + public boolean eventPresent() { + return eventing == EventingState.EVENT_PRESENT; + } + + public boolean noEventPresent() { + return eventing == EventingState.NO_EVENT_PRESENT; + } + + public void unMarkEventReceived() { + switch (eventing) { + case EVENT_PRESENT: + eventing = EventingState.NO_EVENT_PRESENT; + break; + case PROCESSED_MARK_FOR_DELETION: + throw new IllegalStateException("Cannot unmark processed marked for deletion."); + case DELETE_EVENT_PRESENT: + throw new IllegalStateException("Cannot unmark delete event."); + case NO_EVENT_PRESENT: + // do nothing + break; + } + } + + @Override + public String toString() { + return "ResourceState{" + + "id=" + + id + + ", underProcessing=" + + underProcessing + + ", retry=" + + retry + + ", eventing=" + + eventing + + ", rateLimit=" + + rateLimit + + '}'; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManager.java new file mode 100644 index 0000000000..481fd317ff --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManager.java @@ -0,0 +1,49 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; + +class ResourceStateManager { + // maybe we should have a way for users to specify a hint on the amount of CRs their reconciler + // will process to avoid under- or over-sizing the state maps and avoid too many resizing that + // take time and memory? + private final Map states = new ConcurrentHashMap<>(100); + + public Optional getOrCreateOnResourceEvent(Event event) { + var resourceId = event.getRelatedCustomResourceID(); + var state = states.get(event.getRelatedCustomResourceID()); + if (state != null) { + return Optional.of(state); + } + if (event instanceof ResourceEvent) { + state = new ResourceState(resourceId); + states.put(resourceId, state); + return Optional.of(state); + } else { + return Optional.empty(); + } + } + + public ResourceState getOrCreate(ResourceID resourceID) { + return states.computeIfAbsent(resourceID, ResourceState::new); + } + + public ResourceState remove(ResourceID resourceID) { + return states.remove(resourceID); + } + + public boolean contains(ResourceID resourceID) { + return states.containsKey(resourceID); + } + + public List resourcesWithEventPresent() { + return states.values().stream() + .filter(state -> !state.noEventPresent()) + .collect(Collectors.toList()); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiter.java new file mode 100644 index 0000000000..9ebea4f081 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiter.java @@ -0,0 +1,78 @@ +package io.javaoperatorsdk.operator.processing.event.rate; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; + +import io.javaoperatorsdk.operator.api.config.AnnotationConfigurable; + +/** A simple rate limiter that limits the number of permission for a time interval. */ +public class LinearRateLimiter + implements RateLimiter, AnnotationConfigurable { + + /** To turn off rate limiting set limit for period to a non-positive number */ + public static final int NO_LIMIT_PERIOD = -1; + + public static final int DEFAULT_REFRESH_PERIOD_SECONDS = 10; + public static final Duration DEFAULT_REFRESH_PERIOD = + Duration.ofSeconds(DEFAULT_REFRESH_PERIOD_SECONDS); + + private Duration refreshPeriod; + private int limitForPeriod; + + public static LinearRateLimiter deactivatedRateLimiter() { + return new LinearRateLimiter(); + } + + public LinearRateLimiter() { + this(DEFAULT_REFRESH_PERIOD, NO_LIMIT_PERIOD); + } + + public LinearRateLimiter(Duration refreshPeriod, int limitForPeriod) { + this.refreshPeriod = refreshPeriod; + this.limitForPeriod = limitForPeriod; + } + + @Override + public Optional isLimited(RateLimitState rateLimitState) { + if (!isActivated() || !(rateLimitState instanceof RateState actualState)) { + return Optional.empty(); + } + + if (actualState.getCount() < limitForPeriod) { + actualState.increaseCount(); + return Optional.empty(); + } else if (actualState + .getLastRefreshTime() + .isBefore(LocalDateTime.now().minus(refreshPeriod))) { + actualState.reset(); + actualState.increaseCount(); + return Optional.empty(); + } else { + return Optional.of(Duration.between(actualState.getLastRefreshTime(), LocalDateTime.now())); + } + } + + @Override + public RateState initState() { + return RateState.initialState(); + } + + @Override + public void initFrom(RateLimited configuration) { + this.refreshPeriod = Duration.of(configuration.within(), configuration.unit().toChronoUnit()); + this.limitForPeriod = configuration.maxReconciliations(); + } + + public boolean isActivated() { + return limitForPeriod > 0; + } + + public int getLimitForPeriod() { + return limitForPeriod; + } + + public Duration getRefreshPeriod() { + return refreshPeriod; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/RateLimited.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/RateLimited.java new file mode 100644 index 0000000000..66ab6849ed --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/RateLimited.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.operator.processing.event.rate; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface RateLimited { + + int maxReconciliations(); + + int within(); + + /** + * @return time unit for max delay between reconciliations + */ + TimeUnit unit() default TimeUnit.SECONDS; +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/RateLimiter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/RateLimiter.java new file mode 100644 index 0000000000..c856ca2197 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/RateLimiter.java @@ -0,0 +1,19 @@ +package io.javaoperatorsdk.operator.processing.event.rate; + +import java.time.Duration; +import java.util.Optional; + +import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter.RateLimitState; + +public interface RateLimiter { + interface RateLimitState {} + + /** + * @param rateLimitState state implementation + * @return empty if permission acquired or minimal duration until a permission could be acquired + * again + */ + Optional isLimited(RateLimitState rateLimitState); + + S initState(); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/RateState.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/RateState.java new file mode 100644 index 0000000000..e466782996 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/rate/RateState.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.processing.event.rate; + +import java.time.LocalDateTime; + +import io.javaoperatorsdk.operator.processing.event.rate.RateLimiter.RateLimitState; + +class RateState implements RateLimitState { + + private LocalDateTime lastRefreshTime; + private int count; + + public static RateState initialState() { + return new RateState(LocalDateTime.now(), 0); + } + + RateState(LocalDateTime lastRefreshTime, int count) { + this.lastRefreshTime = lastRefreshTime; + this.count = count; + } + + public void increaseCount() { + count = count + 1; + } + + public void reset() { + lastRefreshTime = LocalDateTime.now(); + count = 0; + } + + public LocalDateTime getLastRefreshTime() { + return lastRefreshTime; + } + + public int getCount() { + return count; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSource.java new file mode 100644 index 0000000000..fc27e79124 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSource.java @@ -0,0 +1,93 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.processing.event.EventHandler; +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.OnDeleteFilter; +import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; + +public abstract class AbstractEventSource implements EventSource { + + private final Class resourceClass; + + protected OnAddFilter onAddFilter; + protected OnUpdateFilter onUpdateFilter; + protected OnDeleteFilter onDeleteFilter; + protected GenericFilter genericFilter; + + private EventHandler handler; + private volatile boolean running = false; + private EventSourceStartPriority eventSourceStartPriority = EventSourceStartPriority.DEFAULT; + private final String name; + + protected AbstractEventSource(Class resourceClass) { + this(resourceClass, null); + } + + protected AbstractEventSource(Class resourceClass, String name) { + this.name = name == null ? EventSource.super.name() : name; + this.resourceClass = resourceClass; + } + + @Override + public String name() { + return name; + } + + protected EventHandler getEventHandler() { + return handler; + } + + @Override + public void setEventHandler(EventHandler handler) { + this.handler = handler; + } + + public boolean isRunning() { + return running; + } + + @Override + public void start() throws OperatorException { + running = true; + } + + @Override + public void stop() throws OperatorException { + running = false; + } + + @Override + public EventSourceStartPriority priority() { + return eventSourceStartPriority; + } + + public AbstractEventSource setEventSourcePriority( + EventSourceStartPriority eventSourceStartPriority) { + this.eventSourceStartPriority = eventSourceStartPriority; + return this; + } + + @Override + public Class resourceType() { + return resourceClass; + } + + public void setOnAddFilter(OnAddFilter onAddFilter) { + this.onAddFilter = onAddFilter; + } + + public void setOnUpdateFilter(OnUpdateFilter onUpdateFilter) { + this.onUpdateFilter = onUpdateFilter; + } + + public void setOnDeleteFilter(OnDeleteFilter onDeleteFilter) { + this.onDeleteFilter = onDeleteFilter; + } + + public void setGenericFilter(GenericFilter genericFilter) { + this.genericFilter = genericFilter; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/Cache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/Cache.java new file mode 100644 index 0000000000..200faecbd0 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/Cache.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import java.util.Optional; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +@SuppressWarnings({"rawtypes", "unchecked"}) +public interface Cache { + Predicate TRUE = (a) -> true; + + Optional get(ResourceID resourceID); + + default boolean contains(ResourceID resourceID) { + return get(resourceID).isPresent(); + } + + Stream keys(); + + default Stream list() { + return list(TRUE); + } + + Stream list(Predicate predicate); +} 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..a77e6448cb --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/CacheKeyMapper.java @@ -0,0 +1,17 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +public interface CacheKeyMapper { + + String keyFor(R resource); + + /** + * Used if a polling event source handles only single secondary resource. See also docs for: + * {@link ExternalResourceCachingEventSource} + * + * @return static id mapper, all resources are mapped for same id. + * @param secondary resource type + */ + static CacheKeyMapper singleResourceCacheKeyMapper() { + return r -> "id"; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/Configurable.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/Configurable.java new file mode 100644 index 0000000000..ecff7d11c9 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/Configurable.java @@ -0,0 +1,5 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +public interface Configurable { + C configuration(); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java new file mode 100644 index 0000000000..cefe35f6dd --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java @@ -0,0 +1,107 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import java.util.Optional; +import java.util.Set; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.health.EventSourceHealthIndicator; +import io.javaoperatorsdk.operator.health.Status; +import io.javaoperatorsdk.operator.processing.LifecycleAware; +import io.javaoperatorsdk.operator.processing.event.EventHandler; +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.OnDeleteFilter; +import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; + +/** + * Creates an event source to trigger your reconciler whenever something happens to a secondary or + * external resource that should cause a reconciliation of the primary resource. EventSource + * generalizes the concept of Informers and extends it to external (i.e. non Kubernetes) resources. + * + * @param the resource type that this EventSource is associated with + * @param

the primary resource type which reconciler needs to be triggered when events occur on + * resources of type R + */ +public interface EventSource + extends LifecycleAware, EventSourceHealthIndicator { + + static String generateName(EventSource eventSource) { + return eventSource.getClass().getName() + "@" + Integer.toHexString(eventSource.hashCode()); + } + + /** + * Sets the {@link EventHandler} that is linked to your reconciler when this EventSource is + * registered. + * + * @param handler the {@link EventHandler} associated with your reconciler + */ + void setEventHandler(EventHandler handler); + + /** + * Retrieves the EventSource's name so that it can be referred to + * + * @return the EventSource's name + */ + default String name() { + return generateName(this); + } + + /** + * Retrieves the EventSource's starting priority + * + * @return the EventSource's starting priority + * @see EventSourceStartPriority + */ + default EventSourceStartPriority priority() { + return EventSourceStartPriority.DEFAULT; + } + + /** + * Retrieves the resource type associated with this ResourceEventSource + * + * @return the resource type associated with this ResourceEventSource + */ + Class resourceType(); + + /** + * Retrieves the optional unique secondary resource associated with the specified primary + * resource. Note that this operation will fail if multiple resources are associated with the + * specified primary resource. + * + * @param primary the primary resource for which the secondary resource is requested + * @return the secondary resource associated with the specified primary resource + * @throws IllegalStateException if multiple resources are associated with the primary one + */ + default Optional getSecondaryResource(P primary) { + var resources = getSecondaryResources(primary); + if (resources.isEmpty()) { + return Optional.empty(); + } else if (resources.size() == 1) { + return Optional.of(resources.iterator().next()); + } else { + throw new IllegalStateException("More than 1 secondary resource related to primary"); + } + } + + /** + * Retrieves a potential empty set of resources tracked by this EventSource associated with the + * specified primary resource + * + * @param primary the primary resource for which the secondary resource is requested + * @return the set of secondary resources associated with the specified primary + */ + Set getSecondaryResources(P primary); + + void setOnAddFilter(OnAddFilter onAddFilter); + + void setOnUpdateFilter(OnUpdateFilter onUpdateFilter); + + void setOnDeleteFilter(OnDeleteFilter onDeleteFilter); + + void setGenericFilter(GenericFilter genericFilter); + + @Override + default Status getStatus() { + return Status.UNKNOWN; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSourceStartPriority.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSourceStartPriority.java new file mode 100644 index 0000000000..4971b51829 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSourceStartPriority.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +/** + * Defines priority levels for {@link EventSource} implementation to ensure that some sources are + * started before others + */ +public enum EventSourceStartPriority { + + /** + * Event Sources with this priority are started and synced before the event source with DEFAULT + * priority. This is needed if the event source holds information about another resource's state. + * In this situation, it is needed to initialize this event source before the one associated with + * resources which state is being tracked since that state information might be required to + * properly retrieve the other resources. + * + *

For example a {@code ConfigMap} could store the identifier of a fictional external resource + * {@code A}. In this case, the event source tracking {@code A} resources might need the + * identifier from the {@code ConfigMap} to identify and check the state of {@code A} resources. + * This is usually needed before any reconciliation occurs and the only way to ensure the proper + * behavior in this case is to make sure that the event source tracking the {@code ConfigMaps} (in + * this example) is started/cache-synced before the event source for {@code A} resources gets + * started. + */ + RESOURCE_STATE_LOADER, + DEFAULT +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java new file mode 100644 index 0000000000..efaf3232b1 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSource.java @@ -0,0 +1,250 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.dependent.RecentOperationCacheFiller; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +/** + * Handles caching and related operation of external event sources. It can handle multiple secondary + * resources for a single primary resources. + * + *

There are two related concepts to understand: + * + *

    + *
  • CacheKeyMapper - maps/extracts a key used to reference the associated resource in the cache + *
  • Object equals usage - compares if the two resources are the same or same version. + *
+ * + * When a resource is added for a primary resource its key is used to put in a map. Equals is used + * to compare if it's still the same resource, or an updated version of it. Event is emitted only if + * a new resource(s) is received or actually updated or deleted. Delete is detected by a missing + * key. + * + * @param type of polled external secondary resource + * @param

primary resource + */ +public abstract class ExternalResourceCachingEventSource + extends AbstractEventSource implements RecentOperationCacheFiller { + + private static final Logger log = + LoggerFactory.getLogger(ExternalResourceCachingEventSource.class); + + protected final CacheKeyMapper cacheKeyMapper; + + protected Map> cache = new ConcurrentHashMap<>(); + + protected ExternalResourceCachingEventSource( + Class resourceClass, CacheKeyMapper cacheKeyMapper) { + this(null, resourceClass, cacheKeyMapper); + } + + protected ExternalResourceCachingEventSource( + String name, Class resourceClass, CacheKeyMapper cacheKeyMapper) { + super(resourceClass, name); + this.cacheKeyMapper = cacheKeyMapper; + } + + protected synchronized void handleDelete(ResourceID primaryID) { + var res = cache.remove(primaryID); + if (res != null && deleteAcceptedByFilter(res.values())) { + getEventHandler().handleEvent(new Event(primaryID)); + } + } + + protected synchronized void handleDeletes(ResourceID primaryID, Set resource) { + handleDelete( + primaryID, resource.stream().map(cacheKeyMapper::keyFor).collect(Collectors.toSet())); + } + + protected synchronized void handleDelete(ResourceID primaryID, R resource) { + handleDelete(primaryID, Set.of(cacheKeyMapper.keyFor(resource))); + } + + protected synchronized void handleDelete(ResourceID primaryID, Set resourceIDs) { + if (!isRunning()) { + return; + } + var cachedValues = cache.get(primaryID); + List removedResources = + cachedValues == null + ? Collections.emptyList() + : resourceIDs.stream() + .flatMap(id -> Stream.ofNullable(cachedValues.remove(id))) + .collect(Collectors.toList()); + + if (cachedValues != null && cachedValues.isEmpty()) { + cache.remove(primaryID); + } + if (!removedResources.isEmpty() && deleteAcceptedByFilter(removedResources)) { + getEventHandler().handleEvent(new Event(primaryID)); + } + } + + protected synchronized void handleResources(ResourceID primaryID, R actualResource) { + handleResources(primaryID, Set.of(actualResource), true); + } + + protected synchronized void handleResources(ResourceID primaryID, Set newResources) { + handleResources(primaryID, newResources, true); + } + + protected synchronized void handleResources(Map> allNewResources) { + var toDelete = cache.keySet().stream().filter(k -> !allNewResources.containsKey(k)).toList(); + toDelete.forEach(this::handleDelete); + allNewResources.forEach(this::handleResources); + } + + protected synchronized void handleResources( + ResourceID primaryID, Set newResources, boolean propagateEvent) { + log.debug( + "Handling resources update for: {} numberOfResources: {} ", primaryID, newResources.size()); + if (!isRunning()) { + return; + } + var cachedResources = cache.get(primaryID); + if (cachedResources == null) { + cachedResources = Collections.emptyMap(); + } + var newResourcesMap = + newResources.stream().collect(Collectors.toMap(cacheKeyMapper::keyFor, r -> r)); + cache.put(primaryID, newResourcesMap); + if (propagateEvent + && !newResourcesMap.equals(cachedResources) + && acceptedByFiler(cachedResources, newResourcesMap)) { + getEventHandler().handleEvent(new Event(primaryID)); + } + } + + private boolean acceptedByFiler( + Map cachedResourceMap, Map newResourcesMap) { + + var addedResources = new HashMap<>(newResourcesMap); + addedResources.keySet().removeAll(cachedResourceMap.keySet()); + if (onAddFilter != null || genericFilter != null) { + var anyAddAccepted = + addedResources.values().stream() + .anyMatch(r -> acceptedByGenericFiler(r) && onAddFilter.accept(r)); + if (anyAddAccepted) { + return true; + } + } else if (!addedResources.isEmpty()) { + return true; + } + + var deletedResource = new HashMap<>(cachedResourceMap); + deletedResource.keySet().removeAll(newResourcesMap.keySet()); + if (onDeleteFilter != null || genericFilter != null) { + var anyDeleteAccepted = + deletedResource.values().stream() + .anyMatch(r -> acceptedByGenericFiler(r) && onDeleteFilter.accept(r, false)); + if (anyDeleteAccepted) { + return true; + } + } else if (!deletedResource.isEmpty()) { + return true; + } + + Map possibleUpdatedResources = new HashMap<>(cachedResourceMap); + possibleUpdatedResources.keySet().retainAll(newResourcesMap.keySet()); + possibleUpdatedResources = + possibleUpdatedResources.entrySet().stream() + .filter(entry -> !newResourcesMap.get(entry.getKey()).equals(entry.getValue())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + if (onUpdateFilter != null || genericFilter != null) { + return possibleUpdatedResources.entrySet().stream() + .anyMatch( + entry -> { + var newResource = newResourcesMap.get(entry.getKey()); + return acceptedByGenericFiler(newResource) + && onUpdateFilter.accept(newResource, entry.getValue()); + }); + } else return !possibleUpdatedResources.isEmpty(); + } + + private boolean acceptedByGenericFiler(R resource) { + return genericFilter == null || genericFilter.accept(resource); + } + + @Override + public synchronized void handleRecentResourceCreate(ResourceID primaryID, R resource) { + var actualValues = cache.get(primaryID); + var resourceId = cacheKeyMapper.keyFor(resource); + if (actualValues == null) { + actualValues = new HashMap<>(); + cache.put(primaryID, actualValues); + actualValues.put(resourceId, resource); + } else { + actualValues.computeIfAbsent(resourceId, r -> resource); + } + } + + @Override + public synchronized void handleRecentResourceUpdate( + ResourceID primaryID, R resource, R previousVersionOfResource) { + var actualValues = cache.get(primaryID); + if (actualValues != null) { + var resourceId = cacheKeyMapper.keyFor(resource); + R actualResource = actualValues.get(resourceId); + if (actualResource.equals(previousVersionOfResource)) { + actualValues.put(resourceId, resource); + } + } + } + + @Override + public Set getSecondaryResources(P primary) { + return getSecondaryResources(ResourceID.fromResource(primary)); + } + + public Set getSecondaryResources(ResourceID primaryID) { + var cachedValues = cache.get(primaryID); + if (cachedValues == null) { + return Collections.emptySet(); + } else { + return new HashSet<>(cache.get(primaryID).values()); + } + } + + public Optional getSecondaryResource(ResourceID primaryID) { + var resources = getSecondaryResources(primaryID); + if (resources.isEmpty()) { + return Optional.empty(); + } else if (resources.size() == 1) { + return Optional.of(resources.iterator().next()); + } else { + throw new IllegalStateException("More than 1 secondary resource related to primary"); + } + } + + public Map> getCache() { + return Collections.unmodifiableMap(cache); + } + + protected boolean deleteAcceptedByFilter(Collection res) { + if (onDeleteFilter == null) { + return true; + } + // it is enough if at least one event is accepted + // Cannot be sure about the final state in general, mainly for polled resources. This might be + // fine-tuned for + // other event sources. (For now just by overriding this method.) + return res.stream().anyMatch(r -> onDeleteFilter.accept(r, false)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/IndexerResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/IndexerResourceCache.java new file mode 100644 index 0000000000..3938d8219b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/IndexerResourceCache.java @@ -0,0 +1,17 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.IndexedResourceCache; + +public interface IndexerResourceCache extends IndexedResourceCache { + + void addIndexers(Map>> indexers); + + default void addIndexer(String name, Function> indexer) { + addIndexers(Map.of(name, indexer)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/PrimaryToSecondaryMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/PrimaryToSecondaryMapper.java new file mode 100644 index 0000000000..0e13293886 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/PrimaryToSecondaryMapper.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import java.util.Set; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +/** + * Identifies the set of secondary resources associated with a given primary resource. This is + * typically needed when multiple secondary resources can be associated with one or several multiple + * primary resources *without* a standard way (e.g. owner reference or annotations) to materialize + * that relations. When owner references are present, a {@code PrimaryToSecondaryMapper} instance + * should not be needed. In other words, associating such a mapper with your {@link + * InformerEventSourceConfiguration} is usually needed when your secondary resources are referenced + * in some way by your primary resource but that this link does not exist in the secondary resource + * information. The mapper implementation instructs the SDK on how to find all the secondary + * resources associated with a given primary resource so that this primary resource can properly be + * reconciled when changes impact the associated secondary resources, even though these don't + * contain any information allowing to make such an inference. + * + *

This helps particularly in cases where several secondary resources, listed in some way in the + * primary resource, need to or can be created before the primary resource exists. In that + * situation, attempting to retrieve the associated secondary resources by calling {@link + * io.javaoperatorsdk.operator.api.reconciler.Context#getSecondaryResource(Class)} would fail + * without providing a mapper to tell JOSDK how to retrieve the secondary resources. + * + *

You can see an example of this in action in the Reconciler + * for the PrimaryToSecondaryIT integration tests that handles many-to-many relationship. + * + * @param

primary resource type + */ +public interface PrimaryToSecondaryMapper

{ + + Set toSecondaryResourceIDs(P primary); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventAware.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventAware.java new file mode 100644 index 0000000000..9ff14d83e0 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventAware.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public interface ResourceEventAware { + + default void onResourceCreated(T resource) {} + + default void onResourceUpdated(T newResource, T oldResource) {} + + default void onResourceDeleted(T resource) {} +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java new file mode 100644 index 0000000000..328f3854bd --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java @@ -0,0 +1,19 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import java.util.Set; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +/** + * Maps secondary resource to primary resources. + * + * @param secondary resource type + */ +@FunctionalInterface +public interface SecondaryToPrimaryMapper { + /** + * @param resource - secondary + * @return set of primary resource IDs + */ + Set toPrimaryResourceIDs(R resource); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/UpdatableCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/UpdatableCache.java new file mode 100644 index 0000000000..7e0fe56e59 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/UpdatableCache.java @@ -0,0 +1,9 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public interface UpdatableCache extends Cache { + T remove(ResourceID key); + + void put(ResourceID key, T resource); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedCache.java new file mode 100644 index 0000000000..4c6deb82b1 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedCache.java @@ -0,0 +1,10 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache; + +public interface BoundedCache { + + R get(K key); + + R remove(K key); + + void put(K key, R object); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedItemStore.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedItemStore.java new file mode 100644 index 0000000000..f3f0c8e1a0 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedItemStore.java @@ -0,0 +1,152 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.informers.cache.Cache; +import io.fabric8.kubernetes.client.informers.cache.ItemStore; +import io.javaoperatorsdk.operator.api.config.Utils; + +public class BoundedItemStore implements ItemStore { + + private static final Logger log = LoggerFactory.getLogger(BoundedItemStore.class); + + private final ResourceFetcher resourceFetcher; + private final BoundedCache cache; + private final Function keyFunction; + private final Map existingMinimalResources = new ConcurrentHashMap<>(); + private final Constructor resourceConstructor; + + public BoundedItemStore( + BoundedCache cache, Class resourceClass, KubernetesClient client) { + this( + cache, + resourceClass, + namespaceKeyFunc(), + new KubernetesResourceFetcher<>(resourceClass, client)); + } + + public BoundedItemStore( + BoundedCache cache, + Class resourceClass, + Function keyFunction, + ResourceFetcher resourceFetcher) { + this.resourceFetcher = resourceFetcher; + this.cache = cache; + this.keyFunction = keyFunction; + this.resourceConstructor = Utils.getConstructor(resourceClass); + } + + @Override + public String getKey(R obj) { + return keyFunction.apply(obj); + } + + @Override + public synchronized R put(String key, R obj) { + var result = existingMinimalResources.get(key); + cache.put(key, obj); + existingMinimalResources.put(key, createMinimalResource(obj)); + return result; + } + + private R createMinimalResource(R obj) { + try { + R minimal = resourceConstructor.newInstance(); + final var metadata = obj.getMetadata(); + minimal.setMetadata( + new ObjectMetaBuilder() + .withName(metadata.getName()) + .withNamespace(metadata.getNamespace()) + .withResourceVersion(metadata.getResourceVersion()) + .build()); + return minimal; + } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new IllegalStateException(e); + } + } + + @Override + public synchronized R remove(String key) { + var fullValue = cache.remove(key); + var minimalValue = existingMinimalResources.remove(key); + return fullValue != null ? fullValue : minimalValue; + } + + @Override + public Stream keySet() { + return existingMinimalResources.keySet().stream(); + } + + @Override + public Stream values() { + return existingMinimalResources.values().stream(); + } + + @Override + public int size() { + return existingMinimalResources.size(); + } + + @Override + public R get(String key) { + var res = cache.get(key); + if (res != null) { + return res; + } + if (!existingMinimalResources.containsKey(key)) { + return null; + } else { + return refreshMissingStateFromServer(key); + } + } + + @Override + public boolean isFullState() { + return false; + } + + public static Function namespaceKeyFunc() { + return r -> Cache.namespaceKeyFunc(r.getMetadata().getNamespace(), r.getMetadata().getName()); + } + + protected R refreshMissingStateFromServer(String key) { + log.debug("Fetching resource from server for key: {}", key); + var newRes = resourceFetcher.fetchResource(key); + synchronized (this) { + log.debug("Fetched resource: {}", newRes); + var actual = cache.get(key); + if (newRes == null) { + // double-checking if actual, not received since. + // If received we just return. Since the resource from informer should be always leading, + // even if the fetched resource is null, this will be eventually received as an event. + if (actual == null) { + existingMinimalResources.remove(key); + return null; + } else { + return actual; + } + } + // Just want to put the fetched resource if there is still no resource published from + // different source. In case of informers actually multiple events might arrive, therefore non + // fetched resource should take always precedence. + if (actual == null) { + cache.put(key, newRes); + existingMinimalResources.put(key, createMinimalResource(newRes)); + return newRes; + } else { + return actual; + } + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/KubernetesResourceFetcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/KubernetesResourceFetcher.java new file mode 100644 index 0000000000..cd82f50a22 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/KubernetesResourceFetcher.java @@ -0,0 +1,46 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache; + +import java.util.function.Function; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public class KubernetesResourceFetcher + implements ResourceFetcher { + + private final Class rClass; + private final KubernetesClient client; + private final Function resourceIDFunction; + + public KubernetesResourceFetcher(Class rClass, KubernetesClient client) { + this(rClass, client, inverseNamespaceKeyFunction()); + } + + public KubernetesResourceFetcher( + Class rClass, KubernetesClient client, Function resourceIDFunction) { + this.rClass = rClass; + this.client = client; + this.resourceIDFunction = resourceIDFunction; + } + + @Override + public R fetchResource(String key) { + var resourceId = resourceIDFunction.apply(key); + return resourceId + .getNamespace() + .map(ns -> client.resources(rClass).inNamespace(ns).withName(resourceId.getName()).get()) + .orElse(client.resources(rClass).withName(resourceId.getName()).get()); + } + + public static Function inverseNamespaceKeyFunction() { + return s -> { + int delimiterIndex = s.indexOf("/"); + if (delimiterIndex == -1) { + return new ResourceID(s); + } else { + return new ResourceID(s.substring(delimiterIndex + 1), s.substring(0, delimiterIndex)); + } + }; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/ResourceFetcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/ResourceFetcher.java new file mode 100644 index 0000000000..702b9efdb9 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/ResourceFetcher.java @@ -0,0 +1,6 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache; + +public interface ResourceFetcher { + + R fetchResource(K key); +} 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 new file mode 100644 index 0000000000..eb9f65eafc --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -0,0 +1,141 @@ +package io.javaoperatorsdk.operator.processing.event.source.controller; + +import java.util.Optional; +import java.util.Set; + +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.informers.ResourceEventHandler; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +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.filter.OnDeleteFilter; +import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; +import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; + +import static io.javaoperatorsdk.operator.ReconcilerUtils.handleKubernetesClientException; +import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; +import static io.javaoperatorsdk.operator.processing.event.source.controller.InternalEventFilters.*; + +public class ControllerEventSource + extends ManagedInformerEventSource> + implements ResourceEventHandler { + + private static final Logger log = LoggerFactory.getLogger(ControllerEventSource.class); + public static final String NAME = "ControllerResourceEventSource"; + + private final Controller controller; + + @SuppressWarnings({"unchecked", "rawtypes"}) + public ControllerEventSource(Controller controller) { + super(NAME, controller.getCRClient(), controller.getConfiguration(), false); + this.controller = controller; + + final var config = controller.getConfiguration(); + OnUpdateFilter internalOnUpdateFilter = + onUpdateFinalizerNeededAndApplied(controller.useFinalizer(), config.getFinalizerName()) + .or(onUpdateGenerationAware(config.isGenerationAware())) + .or(onUpdateMarkedForDeletion()); + + // by default the on add should be processed in all cases regarding internal filters + final var informerConfig = config.getInformerConfig(); + Optional.ofNullable(informerConfig.getOnAddFilter()).ifPresent(this::setOnAddFilter); + Optional.ofNullable(informerConfig.getOnUpdateFilter()) + .ifPresentOrElse( + filter -> setOnUpdateFilter(filter.and(internalOnUpdateFilter)), + () -> setOnUpdateFilter(internalOnUpdateFilter)); + Optional.ofNullable(informerConfig.getGenericFilter()).ifPresent(this::setGenericFilter); + setControllerConfiguration(config); + } + + @Override + public synchronized void start() { + try { + super.start(); + } catch (KubernetesClientException e) { + handleKubernetesClientException(e, controller.getConfiguration().getResourceTypeName()); + throw e; + } + } + + public void eventReceived(ResourceAction action, T resource, T oldResource) { + try { + if (log.isDebugEnabled()) { + log.debug( + "Event received for resource: {} version: {} uuid: {} action: {}", + ResourceID.fromResource(resource), + getVersion(resource), + resource.getMetadata().getUid(), + action); + log.trace("Event Old resource: {},\n new resource: {}", oldResource, resource); + } + MDCUtils.addResourceInfo(resource); + controller.getEventSourceManager().broadcastOnResourceEvent(action, resource, oldResource); + if (isAcceptedByFilters(action, resource, oldResource)) { + getEventHandler() + .handleEvent(new ResourceEvent(action, ResourceID.fromResource(resource), resource)); + } else { + log.debug("Skipping event handling resource {}", ResourceID.fromResource(resource)); + } + } finally { + MDCUtils.removeResourceInfo(); + } + } + + private boolean isAcceptedByFilters(ResourceAction action, T resource, T oldResource) { + // delete event is filtered for generic filter only. + 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; + } + + @Override + public void onAdd(T resource) { + super.onAdd(resource); + eventReceived(ResourceAction.ADDED, resource, null); + } + + @Override + public void onUpdate(T oldCustomResource, T newCustomResource) { + super.onUpdate(oldCustomResource, newCustomResource); + eventReceived(ResourceAction.UPDATED, newCustomResource, oldCustomResource); + } + + @Override + public void onDelete(T resource, boolean b) { + super.onDelete(resource, b); + eventReceived(ResourceAction.DELETED, resource, null); + } + + @Override + public Optional getSecondaryResource(T primary) { + throw new IllegalStateException("This method should not be called here. Primary: " + primary); + } + + @Override + public Set getSecondaryResources(T primary) { + throw new IllegalStateException("This method should not be called here. Primary: " + primary); + } + + @Override + public void setOnDeleteFilter(OnDeleteFilter onDeleteFilter) { + throw new IllegalStateException( + "onDeleteFilter is not supported for controller resource event source"); + } + + @Override + public String name() { + return NAME; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFilters.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFilters.java new file mode 100644 index 0000000000..3b87778b2b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFilters.java @@ -0,0 +1,49 @@ +package io.javaoperatorsdk.operator.processing.event.source.controller; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; + +public class InternalEventFilters { + + private InternalEventFilters() {} + + static OnUpdateFilter onUpdateMarkedForDeletion() { + // the old resource is checked since in corner cases users might still want to update the status + // for a resource that is marked for deletion + + return (newResource, oldResource) -> + !oldResource.isMarkedForDeletion() && newResource.isMarkedForDeletion(); + } + + static OnUpdateFilter onUpdateGenerationAware( + boolean generationAware) { + + return (newResource, oldResource) -> { + if (!generationAware) { + return true; + } + // for example pods don't have generation + if (oldResource.getMetadata().getGeneration() == null) { + return true; + } + + return oldResource.getMetadata().getGeneration() < newResource.getMetadata().getGeneration(); + }; + } + + static OnUpdateFilter onUpdateFinalizerNeededAndApplied( + boolean useFinalizer, String finalizerName) { + return (newResource, oldResource) -> { + if (useFinalizer) { + boolean oldFinalizer = oldResource.hasFinalizer(finalizerName); + boolean newFinalizer = newResource.hasFinalizer(finalizerName); + // accepts event if old did not have finalizer, since it was just added, so the event needs + // to + // be published. + return !newFinalizer || !oldFinalizer; + } else { + return false; + } + }; + } +} 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/controller/ResourceAction.java new file mode 100644 index 0000000000..d1dbcb9e1b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceAction.java @@ -0,0 +1,7 @@ +package io.javaoperatorsdk.operator.processing.event.source.controller; + +public enum ResourceAction { + ADDED, + UPDATED, + DELETED +} 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 new file mode 100644 index 0000000000..f97cedf7f5 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEvent.java @@ -0,0 +1,52 @@ +package io.javaoperatorsdk.operator.processing.event.source.controller; + +import java.util.Objects; +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public class ResourceEvent extends Event { + + private final ResourceAction action; + private final HasMetadata resource; + + public ResourceEvent(ResourceAction action, ResourceID resourceID, HasMetadata resource) { + super(resourceID); + this.action = action; + this.resource = resource; + } + + @Override + public String toString() { + return "ResourceEvent{" + + "action=" + + action + + ", associated resource id=" + + getRelatedCustomResourceID() + + '}'; + } + + public ResourceAction getAction() { + return action; + } + + public Optional getResource() { + return Optional.ofNullable(resource); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + ResourceEvent that = (ResourceEvent) o; + return action == that.action; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), action); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/GenericFilter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/GenericFilter.java new file mode 100644 index 0000000000..fe949cbd34 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/GenericFilter.java @@ -0,0 +1,19 @@ +package io.javaoperatorsdk.operator.processing.event.source.filter; + +@FunctionalInterface +public interface GenericFilter { + + boolean accept(R resource); + + default GenericFilter and(GenericFilter genericFilter) { + return (resource) -> this.accept(resource) && genericFilter.accept(resource); + } + + default GenericFilter or(GenericFilter genericFilter) { + return (resource) -> this.accept(resource) || genericFilter.accept(resource); + } + + default GenericFilter not() { + return (resource) -> !this.accept(resource); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnAddFilter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnAddFilter.java new file mode 100644 index 0000000000..c14dcd0452 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnAddFilter.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.processing.event.source.filter; + +@FunctionalInterface +public interface OnAddFilter { + boolean accept(R resource); + + default OnAddFilter and(OnAddFilter onAddFilter) { + return (resource) -> this.accept(resource) && onAddFilter.accept(resource); + } + + default OnAddFilter or(OnAddFilter onAddFilter) { + return (resource) -> this.accept(resource) || onAddFilter.accept(resource); + } + + default OnAddFilter not() { + return (resource) -> !this.accept(resource); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnDeleteFilter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnDeleteFilter.java new file mode 100644 index 0000000000..1e87648425 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnDeleteFilter.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.operator.processing.event.source.filter; + +@FunctionalInterface +public interface OnDeleteFilter { + + boolean accept(R hasMetadata, Boolean deletedFinalStateUnknown); + + default OnDeleteFilter and(OnDeleteFilter OnDeleteFilter) { + return (resource, deletedFinalStateUnknown) -> + this.accept(resource, deletedFinalStateUnknown) + && OnDeleteFilter.accept(resource, deletedFinalStateUnknown); + } + + default OnDeleteFilter or(OnDeleteFilter OnDeleteFilter) { + return (resource, deletedFinalStateUnknown) -> + this.accept(resource, deletedFinalStateUnknown) + || OnDeleteFilter.accept(resource, deletedFinalStateUnknown); + } + + default OnDeleteFilter not() { + return (resource, deletedFinalStateUnknown) -> !this.accept(resource, deletedFinalStateUnknown); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnUpdateFilter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnUpdateFilter.java new file mode 100644 index 0000000000..dec1d9be6e --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/filter/OnUpdateFilter.java @@ -0,0 +1,21 @@ +package io.javaoperatorsdk.operator.processing.event.source.filter; + +@FunctionalInterface +public interface OnUpdateFilter { + + boolean accept(R newResource, R oldResource); + + default OnUpdateFilter and(OnUpdateFilter onUpdateFilter) { + return (newResource, oldResource) -> + this.accept(newResource, oldResource) && onUpdateFilter.accept(newResource, oldResource); + } + + default OnUpdateFilter or(OnUpdateFilter onUpdateFilter) { + return (newResource, oldResource) -> + this.accept(newResource, oldResource) || onUpdateFilter.accept(newResource, oldResource); + } + + default OnUpdateFilter not() { + return (newResource, oldResource) -> !this.accept(newResource, oldResource); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/CachingInboundEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/CachingInboundEventSource.java new file mode 100644 index 0000000000..6c3ebf6916 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/CachingInboundEventSource.java @@ -0,0 +1,79 @@ +package io.javaoperatorsdk.operator.processing.event.source.inbound; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.CacheKeyMapper; +import io.javaoperatorsdk.operator.processing.event.source.ExternalResourceCachingEventSource; +import io.javaoperatorsdk.operator.processing.event.source.ResourceEventAware; + +public class CachingInboundEventSource + extends ExternalResourceCachingEventSource implements ResourceEventAware

{ + + private final ResourceFetcher resourceFetcher; + private final Set fetchedForPrimaries = ConcurrentHashMap.newKeySet(); + + public CachingInboundEventSource( + ResourceFetcher resourceFetcher, + Class resourceClass, + CacheKeyMapper cacheKeyMapper) { + super(resourceClass, cacheKeyMapper); + this.resourceFetcher = resourceFetcher; + } + + public void handleResourceEvent(ResourceID primaryID, Set resources) { + super.handleResources(primaryID, resources); + } + + public void handleResourceEvent(ResourceID primaryID, R resource) { + super.handleResources(primaryID, resource); + } + + public void handleResourceDeleteEvent(ResourceID primaryID, String resourceID) { + super.handleDelete(primaryID, Set.of(resourceID)); + } + + @Override + public void onResourceDeleted(P resource) { + var resourceID = ResourceID.fromResource(resource); + fetchedForPrimaries.remove(resourceID); + } + + private Set getAndCacheResource(P primary) { + var primaryID = ResourceID.fromResource(primary); + var values = resourceFetcher.fetchResources(primary); + handleResources(primaryID, values, false); + fetchedForPrimaries.add(primaryID); + return values; + } + + /** + * When this event source is queried for the resource, it might not be fully "synced". Thus, the + * cache might not be propagated, therefore the supplier is checked for the resource too. + * + * @param primary resource of the controller + * @return the related resource for this event source + */ + @Override + public Set getSecondaryResources(P primary) { + var primaryID = ResourceID.fromResource(primary); + var cachedValue = cache.get(primaryID); + if (cachedValue != null && !cachedValue.isEmpty()) { + return new HashSet<>(cachedValue.values()); + } else { + if (fetchedForPrimaries.contains(primaryID)) { + return Collections.emptySet(); + } else { + return getAndCacheResource(primary); + } + } + } + + public interface ResourceFetcher { + Set fetchResources(P primaryResource); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/SimpleInboundEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/SimpleInboundEventSource.java new file mode 100644 index 0000000000..7d5f2aa446 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/inbound/SimpleInboundEventSource.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.processing.event.source.inbound; + +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.AbstractEventSource; + +public class SimpleInboundEventSource

extends AbstractEventSource { + + private static final Logger log = LoggerFactory.getLogger(SimpleInboundEventSource.class); + + public SimpleInboundEventSource() { + super(Void.class); + } + + public SimpleInboundEventSource(String name) { + super(Void.class, name); + } + + public void propagateEvent(ResourceID resourceID) { + if (isRunning()) { + getEventHandler().handleEvent(new Event(resourceID)); + } else { + log.debug("Event source not started yet, not propagating event for: {}", resourceID); + } + } + + @Override + public Set getSecondaryResources(P primary) { + return Set.of(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java new file mode 100644 index 0000000000..a1a5a96d36 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java @@ -0,0 +1,57 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; + +class DefaultPrimaryToSecondaryIndex implements PrimaryToSecondaryIndex { + + private final SecondaryToPrimaryMapper secondaryToPrimaryMapper; + private final Map> index = new HashMap<>(); + + public DefaultPrimaryToSecondaryIndex(SecondaryToPrimaryMapper secondaryToPrimaryMapper) { + this.secondaryToPrimaryMapper = secondaryToPrimaryMapper; + } + + @Override + public synchronized void onAddOrUpdate(R resource) { + Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); + primaryResources.forEach( + primaryResource -> { + var resourceSet = + index.computeIfAbsent(primaryResource, pr -> ConcurrentHashMap.newKeySet()); + resourceSet.add(ResourceID.fromResource(resource)); + }); + } + + @Override + public synchronized void onDelete(R resource) { + Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); + primaryResources.forEach( + primaryResource -> { + var secondaryResources = index.get(primaryResource); + // this can be null in just very special cases, like when the secondaryToPrimaryMapper is + // changing dynamically. Like if a list of ResourceIDs mapped dynamically extended in the + // mapper between the onAddOrUpdate and onDelete is called. + if (secondaryResources != null) { + secondaryResources.remove(ResourceID.fromResource(resource)); + if (secondaryResources.isEmpty()) { + index.remove(primaryResource); + } + } + }); + } + + @Override + public synchronized Set getSecondaryResources(ResourceID primary) { + var resourceIDs = index.get(primary); + if (resourceIDs == null) { + return Collections.emptySet(); + } else { + return Collections.unmodifiableSet(resourceIDs); + } + } +} 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 new file mode 100644 index 0000000000..c029a54170 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -0,0 +1,343 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +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; + +/** + * 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. + *
+ * + * @param resource type being watched + * @param

type of the associated primary resource + */ +public class InformerEventSource + extends ManagedInformerEventSource> + implements ResourceEventHandler { + + public static final String PREVIOUS_ANNOTATION_KEY = "javaoperatorsdk.io/previous"; + private static final Logger log = LoggerFactory.getLogger(InformerEventSource.class); + // 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()); + } + + InformerEventSource(InformerEventSourceConfiguration configuration, KubernetesClient client) { + this(configuration, client, false); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private InformerEventSource( + InformerEventSourceConfiguration configuration, + KubernetesClient client, + boolean parseResourceVersions) { + super( + configuration.name(), + configuration + .getGroupVersionKind() + .map(gvk -> client.genericKubernetesResources(gvk.apiVersion(), gvk.getKind())) + .orElseGet(() -> (MixedOperation) client.resources(configuration.getResourceClass())), + configuration, + parseResourceVersions); + // If there is a primary to secondary mapper there is no need for primary to secondary index. + primaryToSecondaryMapper = configuration.getPrimaryToSecondaryMapper(); + if (useSecondaryToPrimaryIndex()) { + primaryToSecondaryIndex = + // The index uses the secondary to primary mapper (always present) to build the index + new DefaultPrimaryToSecondaryIndex<>(configuration.getSecondaryToPrimaryMapper()); + } else { + primaryToSecondaryIndex = NOOPPrimaryToSecondaryIndex.getInstance(); + } + + final var informerConfig = configuration.getInformerConfig(); + onAddFilter = informerConfig.getOnAddFilter(); + onUpdateFilter = informerConfig.getOnUpdateFilter(); + onDeleteFilter = informerConfig.getOnDeleteFilter(); + genericFilter = informerConfig.getGenericFilter(); + } + + @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)); + } + + @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, + newObject, + oldObject, + () -> InformerEventSource.super.onUpdate(oldObject, newObject)); + } + + @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); + } + } + + @Override + public synchronized void start() { + super.start(); + // this makes sure that on first reconciliation all resources are + // present on the index + manager().list().forEach(primaryToSecondaryIndex::onAddOrUpdate); + } + + private synchronized void onAddOrUpdate( + Operation operation, R newObject, R oldObject, Runnable superOnOp) { + var resourceID = ResourceID.fromResource(newObject); + + if (canSkipEvent(newObject, oldObject, resourceID)) { + log.debug( + "Skipping event propagation for {}, since was a result of a reconcile action. Resource" + + " ID: {}", + operation, + ResourceID.fromResource(newObject)); + superOnOp.run(); + } 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) { + if (temporaryResourceCache.isKnownResourceVersion(newObject)) { + return true; + } + var res = temporaryResourceCache.getResourceFromCache(resourceID); + if (res.isEmpty()) { + return isEventKnownFromAnnotation(newObject, oldObject); + } + 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; + } + + 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) { + var primaryResourceIdSet = + configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(object); + if (primaryResourceIdSet.isEmpty()) { + return; + } + primaryResourceIdSet.forEach( + resourceId -> { + Event event = new Event(resourceId); + /* + * In fabric8 client for certain cases informers can be created on in a way that they are + * automatically started, what would cause a NullPointerException here, since an event + * might be received between creation and registration. + */ + final EventHandler eventHandler = getEventHandler(); + if (eventHandler != null) { + eventHandler.handleEvent(event); + } + }); + } + + @Override + public Set getSecondaryResources(P primary) { + Set secondaryIDs; + if (useSecondaryToPrimaryIndex()) { + var primaryResourceID = ResourceID.fromResource(primary); + secondaryIDs = primaryToSecondaryIndex.getSecondaryResources(primaryResourceID); + log.debug( + "Using PrimaryToSecondaryIndex to find secondary resources for primary: {}. Found" + + " secondary ids: {} ", + primaryResourceID, + secondaryIDs); + } else { + secondaryIDs = primaryToSecondaryMapper.toSecondaryResourceIDs(primary); + log.debug( + "Using PrimaryToSecondaryMapper to find secondary resources for primary: {}. Found" + + " secondary ids: {} ", + primary, + secondaryIDs); + } + return secondaryIDs.stream() + .map(this::get) + .flatMap(Optional::stream) + .collect(Collectors.toSet()); + } + + @Override + public synchronized void handleRecentResourceUpdate( + ResourceID resourceID, R resource, R previousVersionOfResource) { + handleRecentCreateOrUpdate(Operation.UPDATE, resource, previousVersionOfResource); + } + + @Override + public synchronized void handleRecentResourceCreate(ResourceID resourceID, R resource) { + handleRecentCreateOrUpdate(Operation.ADD, resource, null); + } + + private void handleRecentCreateOrUpdate(Operation operation, R newResource, R oldResource) { + primaryToSecondaryIndex.onAddOrUpdate(newResource); + temporaryResourceCache.putResource( + newResource, + Optional.ofNullable(oldResource) + .map(r -> r.getMetadata().getResourceVersion()) + .orElse(null)); + } + + private boolean useSecondaryToPrimaryIndex() { + return this.primaryToSecondaryMapper == null; + } + + @Override + public boolean allowsNamespaceChanges() { + return configuration().followControllerNamespaceChanges(); + } + + private boolean eventAcceptedByFilter(Operation operation, R newObject, R oldObject) { + if (genericFilter != null && !genericFilter.accept(newObject)) { + return false; + } + if (operation == Operation.ADD) { + return onAddFilter == null || onAddFilter.accept(newObject); + } else { + return onUpdateFilter == null || onUpdateFilter.accept(newObject, oldObject); + } + } + + 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 new file mode 100644 index 0000000000..1e1607dd8b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java @@ -0,0 +1,238 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.KubernetesResourceList; +import io.fabric8.kubernetes.client.dsl.FilterWatchListDeletable; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +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.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.Informable; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.health.InformerHealthIndicator; +import io.javaoperatorsdk.operator.processing.LifecycleAware; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.Cache; +import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_ALL_NAMESPACES; + +class InformerManager> + implements LifecycleAware, IndexerResourceCache { + + private static final Logger log = LoggerFactory.getLogger(InformerManager.class); + + private final Map> sources = new ConcurrentHashMap<>(); + private final C configuration; + private final MixedOperation, Resource> client; + private final ResourceEventHandler eventHandler; + private final Map>> indexers = new HashMap<>(); + private ControllerConfiguration controllerConfiguration; + + InformerManager( + MixedOperation, Resource> client, + C configuration, + ResourceEventHandler eventHandler) { + this.client = client; + this.configuration = configuration; + this.eventHandler = eventHandler; + } + + void setControllerConfiguration(ControllerConfiguration controllerConfiguration) { + this.controllerConfiguration = controllerConfiguration; + } + + @Override + public void start() throws OperatorException { + initSources(); + // make sure informers are all started before proceeding further + controllerConfiguration + .getConfigurationService() + .getExecutorServiceManager() + .boundedExecuteAndWaitForAllToComplete( + sources.values().stream(), + iw -> { + iw.start(); + return null; + }, + iw -> + "InformerStarter-" + + iw.getTargetNamespace() + + "-" + + configuration.getResourceClass().getSimpleName()); + } + + private void initSources() { + if (!sources.isEmpty()) { + throw new IllegalStateException("Some sources already initialized."); + } + final var targetNamespaces = + configuration.getInformerConfig().getEffectiveNamespaces(controllerConfiguration); + if (InformerConfiguration.allNamespacesWatched(targetNamespaces)) { + var source = createEventSourceForNamespace(WATCH_ALL_NAMESPACES); + log.debug("Registered {} -> {} for any namespace", this, source); + } else { + targetNamespaces.forEach( + ns -> { + final var source = createEventSourceForNamespace(ns); + log.debug("Registered {} -> {} for namespace: {}", this, source, ns); + }); + } + } + + C configuration() { + return configuration; + } + + public void changeNamespaces(Set namespaces) { + var sourcesToRemove = + sources.keySet().stream().filter(k -> !namespaces.contains(k)).collect(Collectors.toSet()); + log.debug("Stopped informer {} for namespaces: {}", this, sourcesToRemove); + sourcesToRemove.forEach(k -> sources.remove(k).stop()); + + namespaces.forEach( + ns -> { + if (!sources.containsKey(ns)) { + final InformerWrapper source = createEventSourceForNamespace(ns); + source.start(); + log.debug("Registered new {} -> {} for namespace: {}", this, source, ns); + } + }); + } + + private InformerWrapper createEventSourceForNamespace(String namespace) { + final InformerWrapper source; + final var labelSelector = configuration.getInformerConfig().getLabelSelector(); + if (namespace.equals(WATCH_ALL_NAMESPACES)) { + final var filteredBySelectorClient = client.inAnyNamespace().withLabelSelector(labelSelector); + source = createEventSource(filteredBySelectorClient, eventHandler, WATCH_ALL_NAMESPACES); + } else { + source = + createEventSource( + client.inNamespace(namespace).withLabelSelector(labelSelector), + eventHandler, + namespace); + } + source.addIndexers(indexers); + return source; + } + + private InformerWrapper createEventSource( + FilterWatchListDeletable, Resource> filteredBySelectorClient, + ResourceEventHandler eventHandler, + String namespaceIdentifier) { + final var informerConfig = configuration.getInformerConfig(); + var informer = + Optional.ofNullable(informerConfig.getInformerListLimit()) + .map(filteredBySelectorClient::withLimit) + .orElse(filteredBySelectorClient) + .runnableInformer(0); + Optional.ofNullable(informerConfig.getItemStore()).ifPresent(informer::itemStore); + var source = + new InformerWrapper<>( + informer, controllerConfiguration.getConfigurationService(), namespaceIdentifier); + source.addEventHandler(eventHandler); + sources.put(namespaceIdentifier, source); + return source; + } + + @Override + public void stop() { + sources.forEach( + (ns, source) -> { + try { + log.debug("Stopping informer for namespace: {} -> {}", ns, source); + source.stop(); + } catch (Exception e) { + log.warn("Error stopping informer for namespace: {} -> {}", ns, source, e); + } + }); + sources.clear(); + } + + @Override + public Stream list(Predicate predicate) { + if (predicate == null) { + return sources.values().stream().flatMap(IndexerResourceCache::list); + } + return sources.values().stream().flatMap(i -> i.list(predicate)); + } + + @Override + public Stream list(String namespace, Predicate predicate) { + if (isWatchingAllNamespaces()) { + return getSource(WATCH_ALL_NAMESPACES) + .map(source -> source.list(namespace, predicate)) + .orElseGet(Stream::empty); + } else { + return getSource(namespace).map(source -> source.list(predicate)).orElseGet(Stream::empty); + } + } + + @Override + public Optional get(ResourceID resourceID) { + return getSource(resourceID.getNamespace().orElse(WATCH_ALL_NAMESPACES)) + .flatMap(source -> source.get(resourceID)) + .map( + r -> + controllerConfiguration + .getConfigurationService() + .cloneSecondaryResourcesWhenGettingFromCache() + ? controllerConfiguration.getConfigurationService().getResourceCloner().clone(r) + : r); + } + + @Override + public Stream keys() { + return sources.values().stream().flatMap(Cache::keys); + } + + private boolean isWatchingAllNamespaces() { + return sources.containsKey(WATCH_ALL_NAMESPACES); + } + + private Optional> getSource(String namespace) { + namespace = isWatchingAllNamespaces() || namespace == null ? WATCH_ALL_NAMESPACES : namespace; + return Optional.ofNullable(sources.get(namespace)); + } + + @Override + public void addIndexers(Map>> indexers) { + this.indexers.putAll(indexers); + } + + @Override + public List byIndex(String indexName, String indexKey) { + return sources.values().stream() + .map(s -> s.byIndex(indexName, indexKey)) + .flatMap(List::stream) + .collect(Collectors.toList()); + } + + @Override + public String toString() { + final var informerConfig = configuration.getInformerConfig(); + final var selector = informerConfig.getLabelSelector(); + return "InformerManager [" + + ReconcilerUtils.getResourceTypeNameWithVersion(configuration.getResourceClass()) + + "] watching: " + + informerConfig.getEffectiveNamespaces(controllerConfiguration) + + (selector != null ? " selector: " + selector : ""); + } + + public Map informerHealthIndicators() { + return Collections.unmodifiableMap(sources); + } +} 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 new file mode 100644 index 0000000000..c07ffdbf46 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java @@ -0,0 +1,222 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.informers.ExceptionHandler; +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.OperatorException; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.health.InformerHealthIndicator; +import io.javaoperatorsdk.operator.health.Status; +import io.javaoperatorsdk.operator.processing.LifecycleAware; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache; + +class InformerWrapper + implements LifecycleAware, IndexerResourceCache, InformerHealthIndicator { + + private static final Logger log = LoggerFactory.getLogger(InformerWrapper.class); + + private final SharedIndexInformer informer; + private final Cache cache; + private final String namespaceIdentifier; + private final ConfigurationService configurationService; + + public InformerWrapper( + SharedIndexInformer informer, + ConfigurationService configurationService, + String namespaceIdentifier) { + this.informer = informer; + this.namespaceIdentifier = namespaceIdentifier; + this.cache = (Cache) informer.getStore(); + this.configurationService = configurationService; + } + + @Override + public void start() throws OperatorException { + try { + + // register stopped handler if we have one defined + configurationService + .getInformerStoppedHandler() + .ifPresent( + ish -> { + final var stopped = informer.stopped(); + if (stopped != null) { + stopped.handle( + (res, ex) -> { + ish.onStop(informer, ex); + return null; + }); + } else { + final var apiTypeClass = informer.getApiTypeClass(); + final var fullResourceName = HasMetadata.getFullResourceName(apiTypeClass); + final var version = HasMetadata.getVersion(apiTypeClass); + throw new IllegalStateException( + "Cannot retrieve 'stopped' callback to listen to informer stopping for" + + " informer for " + + fullResourceName + + "/" + + version); + } + }); + if (!configurationService.stopOnInformerErrorDuringStartup()) { + informer.exceptionHandler((b, t) -> !ExceptionHandler.isDeserializationException(t)); + } + // change thread name for easier debugging + final var thread = Thread.currentThread(); + final var name = thread.getName(); + try { + thread.setName(informerInfo() + " " + thread.getId()); + final var resourceName = informer.getApiTypeClass().getSimpleName(); + log.debug( + "Starting informer for namespace: {} resource: {}", namespaceIdentifier, resourceName); + var start = informer.start(); + // note that in case we don't put here timeout and stopOnInformerErrorDuringStartup is + // false, and there is a rbac issue the get never returns; therefore operator never really + // starts + log.trace( + "Waiting informer to start namespace: {} resource: {}", + namespaceIdentifier, + resourceName); + start + .toCompletableFuture() + .get(configurationService.cacheSyncTimeout().toMillis(), TimeUnit.MILLISECONDS); + log.debug( + "Started informer for namespace: {} resource: {}", namespaceIdentifier, resourceName); + } catch (TimeoutException | ExecutionException e) { + if (configurationService.stopOnInformerErrorDuringStartup()) { + log.error("Informer startup error. Operator will be stopped. Informer: {}", informer, e); + throw new OperatorException(e); + } else { + log.warn("Informer startup error. Will periodically retry. Informer: {}", informer, e); + } + } catch (InterruptedException e) { + thread.interrupt(); + throw new IllegalStateException(e); + } finally { + // restore original name + thread.setName(name); + } + + } catch (Exception e) { + ReconcilerUtils.handleKubernetesClientException( + e, HasMetadata.getFullResourceName(informer.getApiTypeClass())); + throw new OperatorException( + "Couldn't start informer for " + versionedFullResourceName() + " resources", e); + } + } + + private String versionedFullResourceName() { + final var apiTypeClass = informer.getApiTypeClass(); + if (apiTypeClass.isAssignableFrom(GenericKubernetesResource.class)) { + return GenericKubernetesResource.class.getSimpleName(); + } + return ReconcilerUtils.getResourceTypeNameWithVersion(apiTypeClass); + } + + @Override + public void stop() throws OperatorException { + informer.stop(); + } + + @Override + public Optional get(ResourceID resourceID) { + return Optional.ofNullable(cache.getByKey(getKey(resourceID))); + } + + private String getKey(ResourceID resourceID) { + return Cache.namespaceKeyFunc(resourceID.getNamespace().orElse(null), resourceID.getName()); + } + + @Override + public Stream list(Predicate predicate) { + return cache.list().stream().filter(predicate); + } + + @Override + public Stream list(String namespace, Predicate predicate) { + final var stream = + cache.list().stream().filter(r -> namespace.equals(r.getMetadata().getNamespace())); + return predicate != null ? stream.filter(predicate) : stream; + } + + @Override + public Stream keys() { + return cache.listKeys().stream().map(Mappers::fromString); + } + + public void addEventHandler(ResourceEventHandler eventHandler) { + informer.addEventHandler(eventHandler); + } + + @Override + public void addIndexers(Map>> indexers) { + informer.getIndexer().addIndexers(indexers); + } + + @Override + public List byIndex(String indexName, String indexKey) { + return informer.getIndexer().byIndex(indexName, indexKey); + } + + @Override + public String toString() { + return informerInfo() + " (" + informer + ')'; + } + + private String informerInfo() { + return "InformerWrapper [" + versionedFullResourceName() + "]"; + } + + @Override + public boolean hasSynced() { + return informer.hasSynced(); + } + + @Override + public boolean isWatching() { + return informer.isWatching(); + } + + @Override + public boolean isRunning() { + return informer.isRunning(); + } + + @Override + public Status getStatus() { + var status = isRunning() && hasSynced() && isWatching() ? Status.HEALTHY : Status.UNHEALTHY; + log.debug( + "Informer status: {} for for type: {}, namespace: {}, details[ is running: {}, has synced:" + + " {}, is watching: {} ]", + status, + informer.getApiTypeClass().getSimpleName(), + namespaceIdentifier, + isRunning(), + hasSynced(), + isWatching()); + return status; + } + + @Override + public String getTargetNamespace() { + return namespaceIdentifier; + } +} 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 new file mode 100644 index 0000000000..549d2236cd --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -0,0 +1,200 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.informers.ResourceEventHandler; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.Informable; +import io.javaoperatorsdk.operator.api.config.NamespaceChangeable; +import io.javaoperatorsdk.operator.api.reconciler.dependent.RecentOperationCacheFiller; +import io.javaoperatorsdk.operator.health.InformerHealthIndicator; +import io.javaoperatorsdk.operator.health.InformerWrappingEventSourceHealthIndicator; +import io.javaoperatorsdk.operator.health.Status; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.*; + +@SuppressWarnings("rawtypes") +public abstract class ManagedInformerEventSource< + R extends HasMetadata, P extends HasMetadata, C extends Informable> + extends AbstractEventSource + implements ResourceEventHandler, + Cache, + IndexerResourceCache, + RecentOperationCacheFiller, + NamespaceChangeable, + InformerWrappingEventSourceHealthIndicator, + Configurable { + + private static final Logger log = LoggerFactory.getLogger(ManagedInformerEventSource.class); + private InformerManager cache; + private final boolean parseResourceVersions; + private ControllerConfiguration controllerConfiguration; + private final C configuration; + private final Map>> indexers = new HashMap<>(); + protected TemporaryResourceCache temporaryResourceCache; + protected MixedOperation client; + + protected ManagedInformerEventSource( + String name, MixedOperation client, C configuration, boolean parseResourceVersions) { + super(configuration.getResourceClass(), name); + this.parseResourceVersions = parseResourceVersions; + 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; + } + + @Override + public void changeNamespaces(Set namespaces) { + if (allowsNamespaceChanges()) { + manager().changeNamespaces(namespaces); + } + } + + @SuppressWarnings("unchecked") + @Override + public synchronized void start() { + if (isRunning()) { + return; + } + temporaryResourceCache = new TemporaryResourceCache<>(this, parseResourceVersions); + this.cache = new InformerManager<>(client, configuration, this); + cache.setControllerConfiguration(controllerConfiguration); + cache.addIndexers(indexers); + manager().start(); + super.start(); + } + + @Override + public synchronized void stop() { + if (!isRunning()) { + return; + } + super.stop(); + manager().stop(); + } + + @Override + public void handleRecentResourceUpdate( + ResourceID resourceID, R resource, R previousVersionOfResource) { + temporaryResourceCache.putResource( + resource, previousVersionOfResource.getMetadata().getResourceVersion()); + } + + @Override + public void handleRecentResourceCreate(ResourceID resourceID, R resource) { + temporaryResourceCache.putAddedResource(resource); + } + + @Override + public Optional get(ResourceID resourceID) { + Optional resource = temporaryResourceCache.getResourceFromCache(resourceID); + if (resource.isPresent()) { + log.debug("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; + } + } + + @SuppressWarnings("unused") + public Optional getCachedValue(ResourceID resourceID) { + return get(resourceID); + } + + @Override + public Stream list(String namespace, Predicate predicate) { + return manager().list(namespace, predicate); + } + + void setTemporalResourceCache(TemporaryResourceCache temporaryResourceCache) { + this.temporaryResourceCache = temporaryResourceCache; + } + + @Override + public void addIndexers(Map>> indexers) { + if (isRunning()) { + throw new OperatorException("Cannot add indexers after InformerEventSource started."); + } + this.indexers.putAll(indexers); + } + + @Override + public List byIndex(String indexName, String indexKey) { + return manager().byIndex(indexName, indexKey); + } + + @Override + public Stream keys() { + return cache.keys(); + } + + @Override + public Stream list(Predicate predicate) { + return cache.list(predicate); + } + + @Override + public Map informerHealthIndicators() { + return cache.informerHealthIndicators(); + } + + @Override + public Status getStatus() { + return InformerWrappingEventSourceHealthIndicator.super.getStatus(); + } + + @Override + public C configuration() { + return configuration; + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "{" + + "resourceClass: " + + configuration().getResourceClass().getSimpleName() + + "}"; + } + + public void setControllerConfiguration(ControllerConfiguration controllerConfiguration) { + this.controllerConfiguration = controllerConfiguration; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/Mappers.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/Mappers.java new file mode 100644 index 0000000000..7ed46a97f3 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/Mappers.java @@ -0,0 +1,179 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; + +public class Mappers { + + public static final String DEFAULT_ANNOTATION_FOR_NAME = "io.javaoperatorsdk/primary-name"; + public static final String DEFAULT_ANNOTATION_FOR_NAMESPACE = + "io.javaoperatorsdk/primary-namespace"; + public static final String DEFAULT_ANNOTATION_FOR_PRIMARY_TYPE = + "io.javaoperatorsdk/primary-type"; + + private Mappers() {} + + public static SecondaryToPrimaryMapper fromAnnotation( + String nameKey, String typeKey, Class primaryResourceType) { + return fromAnnotation(nameKey, null, typeKey, primaryResourceType); + } + + @SuppressWarnings("unused") + public static SecondaryToPrimaryMapper fromAnnotation( + String nameKey, + String namespaceKey, + String typeKey, + Class primaryResourceType) { + return fromMetadata(nameKey, namespaceKey, typeKey, primaryResourceType, false); + } + + @SuppressWarnings("unused") + public static SecondaryToPrimaryMapper fromLabel( + String nameKey, String typeKey, Class primaryResourceType) { + return fromLabel(nameKey, null, typeKey, primaryResourceType); + } + + public static SecondaryToPrimaryMapper fromDefaultAnnotations( + Class primaryResourceType) { + return fromAnnotation( + DEFAULT_ANNOTATION_FOR_NAME, + DEFAULT_ANNOTATION_FOR_NAMESPACE, + DEFAULT_ANNOTATION_FOR_PRIMARY_TYPE, + primaryResourceType); + } + + @SuppressWarnings("unused") + public static SecondaryToPrimaryMapper fromLabel( + String nameKey, + String namespaceKey, + String typeKey, + Class primaryResourceType) { + return fromMetadata(nameKey, namespaceKey, typeKey, primaryResourceType, true); + } + + public static SecondaryToPrimaryMapper fromOwnerReferences( + Class primaryResourceType) { + return fromOwnerReferences(primaryResourceType, false); + } + + public static SecondaryToPrimaryMapper fromOwnerReferences( + Class primaryResourceType, boolean clusterScoped) { + return fromOwnerReferences( + HasMetadata.getApiVersion(primaryResourceType), + HasMetadata.getKind(primaryResourceType), + clusterScoped); + } + + public static SecondaryToPrimaryMapper fromOwnerReferences( + HasMetadata primaryResource) { + return fromOwnerReferences(primaryResource, false); + } + + public static SecondaryToPrimaryMapper fromOwnerReferences( + HasMetadata primaryResource, boolean clusterScoped) { + return fromOwnerReferences( + primaryResource.getApiVersion(), primaryResource.getKind(), clusterScoped); + } + + public static SecondaryToPrimaryMapper fromOwnerReferences( + String apiVersion, String kind, boolean clusterScope) { + String correctApiVersion = apiVersion.startsWith("/") ? apiVersion.substring(1) : apiVersion; + return resource -> + resource.getMetadata().getOwnerReferences().stream() + .filter(r -> r.getKind().equals(kind) && r.getApiVersion().equals(correctApiVersion)) + .map(or -> ResourceID.fromOwnerReference(resource, or, clusterScope)) + .collect(Collectors.toSet()); + } + + private static SecondaryToPrimaryMapper fromMetadata( + String nameKey, + String namespaceKey, + String typeKey, + Class primaryResourceType, + boolean isLabel) { + return resource -> { + final var metadata = resource.getMetadata(); + if (metadata == null) { + return Collections.emptySet(); + } else { + final var map = isLabel ? metadata.getLabels() : metadata.getAnnotations(); + if (map == null) { + return Collections.emptySet(); + } + var name = map.get(nameKey); + if (name == null) { + return Collections.emptySet(); + } + var namespace = + namespaceKey == null ? resource.getMetadata().getNamespace() : map.get(namespaceKey); + + String gvkSimple = map.get(typeKey); + + if (gvkSimple != null + && !GroupVersionKind.fromString(gvkSimple) + .equals(GroupVersionKind.gvkFor(primaryResourceType))) { + return Set.of(); + } + + return Set.of(new ResourceID(name, namespace)); + } + }; + } + + public static ResourceID fromString(String cacheKey) { + if (cacheKey == null) { + return null; + } + + final String[] split = cacheKey.split("/"); + return switch (split.length) { + case 1 -> new ResourceID(split[0]); + case 2 -> new ResourceID(split[1], split[0]); + default -> throw new IllegalArgumentException("Cannot extract a ResourceID from " + cacheKey); + }; + } + + /** + * Produces a mapper that will associate a secondary resource with all owners of the primary type. + */ + public static + SecondaryToPrimaryMapper fromOwnerType(Class clazz) { + String kind = HasMetadata.getKind(clazz); + return resource -> { + var meta = resource.getMetadata(); + if (meta == null) { + return Set.of(); + } + var owners = meta.getOwnerReferences(); + if (owners == null || owners.isEmpty()) { + return Set.of(); + } + return owners.stream() + .filter(it -> kind.equals(it.getKind())) + .map(it -> new ResourceID(it.getName(), resource.getMetadata().getNamespace())) + .collect(Collectors.toSet()); + }; + } + + public static class SecondaryToPrimaryFromDefaultAnnotation + implements SecondaryToPrimaryMapper { + + private final Class primaryResourceType; + + public SecondaryToPrimaryFromDefaultAnnotation( + Class primaryResourceType) { + this.primaryResourceType = primaryResourceType; + } + + @Override + public Set toPrimaryResourceIDs(HasMetadata resource) { + return Mappers.fromDefaultAnnotations(primaryResourceType).toPrimaryResourceIDs(resource); + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java new file mode 100644 index 0000000000..abefbba638 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.Set; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +class NOOPPrimaryToSecondaryIndex implements PrimaryToSecondaryIndex { + + @SuppressWarnings("rawtypes") + private static final NOOPPrimaryToSecondaryIndex instance = new NOOPPrimaryToSecondaryIndex(); + + @SuppressWarnings("unchecked") + public static NOOPPrimaryToSecondaryIndex getInstance() { + return instance; + } + + private NOOPPrimaryToSecondaryIndex() {} + + @Override + public void onAddOrUpdate(R resource) { + // empty method because of noop implementation + } + + @Override + public void onDelete(R resource) { + // empty method because of noop implementation + } + + @Override + public Set getSecondaryResources(ResourceID primary) { + throw new UnsupportedOperationException(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java new file mode 100644 index 0000000000..7a87b23272 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.Set; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public interface PrimaryToSecondaryIndex { + + void onAddOrUpdate(R resource); + + void onDelete(R resource); + + Set getSecondaryResources(ResourceID primary); +} 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 new file mode 100644 index 0000000000..af75a5abc4 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -0,0 +1,192 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +/** + * 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. + * + * @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<>(); + + // 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 ExpirationCache knownResourceVersions; + + public TemporaryResourceCache( + ManagedInformerEventSource managedInformerEventSource, + boolean parseResourceVersions) { + this.managedInformerEventSource = managedInformerEventSource; + this.parseResourceVersions = parseResourceVersions; + if (parseResourceVersions) { + // keep up to the 50000 add/updates for up to 5 minutes + knownResourceVersions = new ExpirationCache<>(50000, 600000); + } else { + knownResourceVersions = null; + } + } + + public synchronized void onDeleteEvent(T resource, boolean unknownState) { + tombstones.add(resource.getMetadata().getUid()); + onEvent(resource, unknownState); + } + + public synchronized void onAddOrUpdateEvent(T resource) { + onEvent(resource, false); + } + + synchronized void onEvent(T resource, boolean unknownState) { + cache.computeIfPresent( + ResourceID.fromResource(resource), + (id, cached) -> + (unknownState || !isLaterResourceVersion(id, cached, resource)) ? null : cached); + } + + public synchronized void putAddedResource(T newResource) { + putResource(newResource, null); + } + + /** + * 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) { + if (knownResourceVersions != null) { + knownResourceVersions.add(newResource.getMetadata().getResourceVersion()); + } + var resourceId = ResourceID.fromResource(newResource); + var cachedResource = managedInformerEventSource.get(resourceId).orElse(null); + + boolean moveAhead = false; + if (previousResourceVersion == null && cachedResource == null) { + if (tombstones.contains(newResource.getMetadata().getUid())) { + log.debug( + "Won't resurrect uid {} for resource id: {}", + newResource.getMetadata().getUid(), + resourceId); + return; + } + // we can skip further checks as this is a simple add and there's no previous entry to + // consider + moveAhead = true; + } + + if (moveAhead + || (cachedResource != null + && (cachedResource + .getMetadata() + .getResourceVersion() + .equals(previousResourceVersion)) + || isLaterResourceVersion(resourceId, newResource, cachedResource))) { + log.debug( + "Temporarily moving ahead to target version {} for resource id: {}", + 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); + } + } + + public synchronized boolean isKnownResourceVersion(T resource) { + return knownResourceVersions != null + && knownResourceVersions.contains(resource.getMetadata().getResourceVersion()); + } + + /** + * @return true if {@link ConfigurationService#parseResourceVersionsForEventFilteringAndCaching()} + * is enabled and the resourceVersion of newResource is numerically greater than + * cachedResource, otherwise false + */ + private 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) { + log.debug( + "Could not compare resourceVersions {} and {} for {}", + newResource.getMetadata().getResourceVersion(), + cachedResource.getMetadata().getResourceVersion(), + resourceId); + } + return false; + } + + public synchronized Optional getResourceFromCache(ResourceID resourceID) { + return Optional.ofNullable(cache.get(resourceID)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStore.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStore.java new file mode 100644 index 0000000000..0088f99084 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStore.java @@ -0,0 +1,76 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.informers.cache.Cache; +import io.fabric8.kubernetes.client.informers.cache.ItemStore; + +public class TransformingItemStore implements ItemStore { + + private final Function keyFunction; + private final UnaryOperator transformationFunction; + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + + public TransformingItemStore(UnaryOperator transformationFunction) { + this(Cache::metaNamespaceKeyFunc, transformationFunction); + } + + public TransformingItemStore( + Function keyFunction, UnaryOperator transformationFunction) { + this.keyFunction = keyFunction; + this.transformationFunction = transformationFunction; + } + + @Override + public String getKey(R obj) { + return keyFunction.apply(obj); + } + + @Override + public R put(String key, R obj) { + var originalName = obj.getMetadata().getName(); + var originalNamespace = obj.getMetadata().getNamespace(); + var originalResourceVersion = obj.getMetadata().getResourceVersion(); + + var transformed = transformationFunction.apply(obj); + + transformed.getMetadata().setName(originalName); + transformed.getMetadata().setNamespace(originalNamespace); + transformed.getMetadata().setResourceVersion(originalResourceVersion); + return store.put(key, transformed); + } + + @Override + public R remove(String key) { + return store.remove(key); + } + + @Override + public Stream keySet() { + return store.keySet().stream(); + } + + @Override + public Stream values() { + return store.values().stream(); + } + + @Override + public R get(String key) { + return store.get(key); + } + + @Override + public int size() { + return store.size(); + } + + @Override + public boolean isFullState() { + return false; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingConfiguration.java new file mode 100644 index 0000000000..9a4493ea5b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingConfiguration.java @@ -0,0 +1,40 @@ +package io.javaoperatorsdk.operator.processing.event.source.polling; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.function.Predicate; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.source.CacheKeyMapper; + +public record PerResourcePollingConfiguration( + String name, + ScheduledExecutorService executorService, + CacheKeyMapper cacheKeyMapper, + PerResourcePollingEventSource.ResourceFetcher resourceFetcher, + Predicate

registerPredicate, + Duration defaultPollingPeriod) { + + public static final int DEFAULT_EXECUTOR_THREAD_NUMBER = 1; + + public PerResourcePollingConfiguration( + String name, + ScheduledExecutorService executorService, + CacheKeyMapper cacheKeyMapper, + PerResourcePollingEventSource.ResourceFetcher resourceFetcher, + Predicate

registerPredicate, + Duration defaultPollingPeriod) { + this.name = name; + this.executorService = + executorService == null + ? new ScheduledThreadPoolExecutor(DEFAULT_EXECUTOR_THREAD_NUMBER) + : executorService; + this.cacheKeyMapper = + cacheKeyMapper == null ? CacheKeyMapper.singleResourceCacheKeyMapper() : cacheKeyMapper; + this.resourceFetcher = Objects.requireNonNull(resourceFetcher); + this.registerPredicate = registerPredicate; + this.defaultPollingPeriod = defaultPollingPeriod; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingConfigurationBuilder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingConfigurationBuilder.java new file mode 100644 index 0000000000..4fab88ffd8 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingConfigurationBuilder.java @@ -0,0 +1,60 @@ +package io.javaoperatorsdk.operator.processing.event.source.polling; + +import java.time.Duration; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Predicate; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.source.CacheKeyMapper; + +public final class PerResourcePollingConfigurationBuilder { + + private final Duration defaultPollingPeriod; + private final PerResourcePollingEventSource.ResourceFetcher resourceFetcher; + + private String name; + private Predicate

registerPredicate; + private ScheduledExecutorService executorService; + private CacheKeyMapper cacheKeyMapper; + + public PerResourcePollingConfigurationBuilder( + PerResourcePollingEventSource.ResourceFetcher resourceFetcher, + Duration defaultPollingPeriod) { + this.resourceFetcher = resourceFetcher; + this.defaultPollingPeriod = defaultPollingPeriod; + } + + @SuppressWarnings("unused") + public PerResourcePollingConfigurationBuilder withExecutorService( + ScheduledExecutorService executorService) { + this.executorService = executorService; + return this; + } + + public PerResourcePollingConfigurationBuilder withRegisterPredicate( + Predicate

registerPredicate) { + this.registerPredicate = registerPredicate; + return this; + } + + public PerResourcePollingConfigurationBuilder withCacheKeyMapper( + CacheKeyMapper cacheKeyMapper) { + this.cacheKeyMapper = cacheKeyMapper; + return this; + } + + public PerResourcePollingConfigurationBuilder withName(String name) { + this.name = name; + return this; + } + + public PerResourcePollingConfiguration build() { + return new PerResourcePollingConfiguration<>( + name, + executorService, + cacheKeyMapper, + resourceFetcher, + registerPredicate, + defaultPollingPeriod); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java new file mode 100644 index 0000000000..b6f6cd79cd --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java @@ -0,0 +1,193 @@ +package io.javaoperatorsdk.operator.processing.event.source.polling; + +import java.time.Duration; +import java.util.Collections; +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.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.Cache; +import io.javaoperatorsdk.operator.processing.event.source.ExternalResourceCachingEventSource; +import io.javaoperatorsdk.operator.processing.event.source.ResourceEventAware; + +/** + * Polls the supplier for each controlled resource registered. Resource is registered when created + * if there is no registerPredicate provided. If register predicate provided it is evaluated on + * resource create and/or update to register polling for the event source. + * + *

For other behavior see {@link ExternalResourceCachingEventSource} + * + * @param the resource polled by the event source + * @param

related custom resource + */ +public class PerResourcePollingEventSource + extends ExternalResourceCachingEventSource implements ResourceEventAware

{ + + private static final Logger log = LoggerFactory.getLogger(PerResourcePollingEventSource.class); + + private final Map> scheduledFutures = new ConcurrentHashMap<>(); + private final Cache

primaryResourceCache; + private final Set fetchedForPrimaries = ConcurrentHashMap.newKeySet(); + + private final ScheduledExecutorService executorService; + private final ResourceFetcher resourceFetcher; + private final Predicate

registerPredicate; + private final Duration period; + + public PerResourcePollingEventSource( + Class resourceClass, + EventSourceContext

context, + PerResourcePollingConfiguration config) { + super(config.name(), resourceClass, config.cacheKeyMapper()); + this.primaryResourceCache = context.getPrimaryCache(); + this.resourceFetcher = config.resourceFetcher(); + this.registerPredicate = config.registerPredicate(); + this.executorService = config.executorService(); + this.period = config.defaultPollingPeriod(); + } + + private Set getAndCacheResource(P primary, boolean fromGetter) { + var values = resourceFetcher.fetchResources(primary); + handleResources(ResourceID.fromResource(primary), values, !fromGetter); + fetchedForPrimaries.add(ResourceID.fromResource(primary)); + return values; + } + + @SuppressWarnings("unchecked") + private void scheduleNextExecution(P primary, Set actualResources) { + var primaryID = ResourceID.fromResource(primary); + var fetchDelay = resourceFetcher.fetchDelay(actualResources, primary); + var fetchDuration = fetchDelay.orElse(period); + + ScheduledFuture scheduledFuture = + (ScheduledFuture) + executorService.schedule( + new FetchingExecutor(primaryID), fetchDuration.toMillis(), TimeUnit.MILLISECONDS); + scheduledFutures.put(primaryID, scheduledFuture); + } + + @Override + public void onResourceCreated(P resource) { + checkAndRegisterTask(resource); + } + + @Override + public void onResourceUpdated(P newResource, P oldResource) { + checkAndRegisterTask(newResource); + } + + @Override + public void onResourceDeleted(P resource) { + var resourceID = ResourceID.fromResource(resource); + var scheduledFuture = scheduledFutures.remove(resourceID); + if (scheduledFuture != null) { + log.debug("Canceling scheduledFuture for resource: {}", resource); + scheduledFuture.cancel(true); + } + handleDelete(resourceID); + fetchedForPrimaries.remove(resourceID); + } + + // This method is always called from the same Thread for the same resource, + // since events from ResourceEventAware are propagated from the thread of the informer. This is + // important because otherwise there will be a race condition related to the timerTasks. + private void checkAndRegisterTask(P resource) { + var primaryID = ResourceID.fromResource(resource); + if (scheduledFutures.get(primaryID) == null + && (registerPredicate == null || registerPredicate.test(resource))) { + var cachedResources = cache.get(primaryID); + var actualResources = + cachedResources == null ? null : new HashSet<>(cachedResources.values()); + // note that there is a delay, to not do two fetches when the resources first appeared + // and getSecondaryResource is called on reconciliation. + scheduleNextExecution(resource, actualResources); + } + } + + private class FetchingExecutor implements Runnable { + private final ResourceID primaryID; + + public FetchingExecutor(ResourceID primaryID) { + this.primaryID = primaryID; + } + + @Override + public void run() { + if (!isRunning()) { + log.debug("Event source not yet started. Will not run for: {}", primaryID); + return; + } + // always use up-to-date resource from cache + var primary = primaryResourceCache.get(primaryID); + if (primary.isEmpty()) { + log.warn("No resource in cache for resource ID: {}", primaryID); + // no new execution is scheduled in this case, an on delete event should be received shortly + } else { + var actualResources = primary.map(p -> getAndCacheResource(p, false)); + scheduleNextExecution(primary.get(), actualResources.orElse(null)); + } + } + } + + /** + * When this event source is queried for the resource, it might not be fully "synced". Thus, the + * cache might not be propagated, therefore the supplier is checked for the resource too. + * + * @param primary resource of the controller + * @return the related resource for this event source + */ + @Override + public Set getSecondaryResources(P primary) { + var primaryID = ResourceID.fromResource(primary); + var cachedValue = cache.get(primaryID); + if (cachedValue != null && !cachedValue.isEmpty()) { + return new HashSet<>(cachedValue.values()); + } else { + if (fetchedForPrimaries.contains(primaryID)) { + return Collections.emptySet(); + } else { + return getAndCacheResource(primary, true); + } + } + } + + public interface ResourceFetcher { + Set fetchResources(P primaryResource); + + /** + * By implementing this method it is possible to specify dynamic durations to wait between the + * polls of the resources. This is especially handy if a resources "stabilized" so it is not + * expected to change its state frequently. For example an AWS RDS instance is up and running, + * it is expected to run and be stable for a very long time. In this case it is enough to poll + * with a lower frequency, compared to the phase when it is being initialized. + * + * @param lastFetchedResource might be null, in case no fetch happened before. Empty set if + * fetch happened but no resources were found. + * @param primary related primary resource + * @return an Optional containing the Duration to wait until the next fetch. If an empty + * Optional is returned, the default polling period will be used. + */ + default Optional fetchDelay(Set lastFetchedResource, P primary) { + return Optional.empty(); + } + } + + @Override + public void stop() throws OperatorException { + super.stop(); + executorService.shutdownNow(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingConfiguration.java new file mode 100644 index 0000000000..f73547a4db --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingConfiguration.java @@ -0,0 +1,25 @@ +package io.javaoperatorsdk.operator.processing.event.source.polling; + +import java.time.Duration; +import java.util.Objects; + +import io.javaoperatorsdk.operator.processing.event.source.CacheKeyMapper; + +public record PollingConfiguration( + String name, + PollingEventSource.GenericResourceFetcher genericResourceFetcher, + Duration period, + CacheKeyMapper cacheKeyMapper) { + + public PollingConfiguration( + String name, + PollingEventSource.GenericResourceFetcher genericResourceFetcher, + Duration period, + CacheKeyMapper cacheKeyMapper) { + this.name = name; + this.genericResourceFetcher = Objects.requireNonNull(genericResourceFetcher); + this.period = period; + this.cacheKeyMapper = + cacheKeyMapper == null ? CacheKeyMapper.singleResourceCacheKeyMapper() : cacheKeyMapper; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingConfigurationBuilder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingConfigurationBuilder.java new file mode 100644 index 0000000000..b86b3be3bb --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingConfigurationBuilder.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.processing.event.source.polling; + +import java.time.Duration; + +import io.javaoperatorsdk.operator.processing.event.source.CacheKeyMapper; + +public final class PollingConfigurationBuilder { + private final Duration period; + private final PollingEventSource.GenericResourceFetcher genericResourceFetcher; + private CacheKeyMapper cacheKeyMapper; + private String name; + + public PollingConfigurationBuilder( + PollingEventSource.GenericResourceFetcher fetcher, Duration period) { + this.genericResourceFetcher = fetcher; + this.period = period; + } + + public PollingConfigurationBuilder withCacheKeyMapper(CacheKeyMapper cacheKeyMapper) { + this.cacheKeyMapper = cacheKeyMapper; + return this; + } + + public PollingConfigurationBuilder withName(String name) { + this.name = name; + return this; + } + + public PollingConfiguration build() { + return new PollingConfiguration<>(name, genericResourceFetcher, period, cacheKeyMapper); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java new file mode 100644 index 0000000000..fe7c9ce391 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSource.java @@ -0,0 +1,106 @@ +package io.javaoperatorsdk.operator.processing.event.source.polling; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.health.Status; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.ExternalResourceCachingEventSource; + +/** + * Polls resource (on contrary to {@link PerResourcePollingEventSource}) not per resource bases but + * instead to calls supplier periodically and independently of the number or state of custom + * resources managed by the controller. It is called on start (synced). This means that when the + * reconciler first time executed on startup the first poll already happened before. So if the cache + * does not contain the target resource it means it is not created yet or was deleted while an + * operator was not running. + * + *

Another caveat with this is if the cached object is checked in the reconciler and created + * since not in the cache it should be manually added to the cache, since it can happen that the + * reconciler is triggered before the cache is propagated with the new resource from a scheduled + * execution. See {@link #handleRecentResourceCreate(ResourceID, Object)} and update method. So the + * generic workflow in reconciler should be: + * + *

    + *
  • Check if the cache contains the resource. + *
  • If cache contains the resource reconcile it - compare with target state, update if + * necessary + *
  • if cache not contains the resource create it. + *
  • If the resource was created or updated, put the new version of the resource manually to the + * cache. + *
+ * + * @param type of the polled resource + * @param

primary resource type + */ +public class PollingEventSource + extends ExternalResourceCachingEventSource { + + private static final Logger log = LoggerFactory.getLogger(PollingEventSource.class); + + private final Timer timer = new Timer(); + private final GenericResourceFetcher genericResourceFetcher; + private final Duration period; + private final AtomicBoolean healthy = new AtomicBoolean(true); + + public PollingEventSource(Class resourceClass, PollingConfiguration config) { + super(config.name(), resourceClass, config.cacheKeyMapper()); + this.genericResourceFetcher = config.genericResourceFetcher(); + this.period = config.period(); + } + + @Override + public void start() throws OperatorException { + super.start(); + getStateAndFillCache(); + timer.schedule( + new TimerTask() { + @Override + public void run() { + try { + if (!isRunning()) { + log.debug("Event source not yet started. Will not run."); + return; + } + getStateAndFillCache(); + healthy.set(true); + } catch (Exception e) { + // Exception is required because of Kotlin + healthy.set(false); + log.error("Error during polling.", e); + } + } + }, + period.toMillis(), + period.toMillis()); + } + + protected synchronized void getStateAndFillCache() { + var values = genericResourceFetcher.fetchResources(); + handleResources(values); + } + + public interface GenericResourceFetcher { + Map> fetchResources(); + } + + @Override + public void stop() throws OperatorException { + super.stop(); + timer.cancel(); + } + + @Override + public Status getStatus() { + return healthy.get() ? Status.HEALTHY : Status.UNHEALTHY; + } +} 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 new file mode 100644 index 0000000000..53c0d328a8 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java @@ -0,0 +1,101 @@ +package io.javaoperatorsdk.operator.processing.event.source.timer; + +import java.util.Map; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.AbstractEventSource; +import io.javaoperatorsdk.operator.processing.event.source.ResourceEventAware; + +public class TimerEventSource extends AbstractEventSource + implements ResourceEventAware { + private static final Logger log = LoggerFactory.getLogger(TimerEventSource.class); + + private Timer timer; + private final Map onceTasks = new ConcurrentHashMap<>(); + + public TimerEventSource() { + super(Void.class); + } + + public TimerEventSource(String name) { + super(Void.class, name); + } + + @SuppressWarnings("unused") + public void scheduleOnce(R resource, long delay) { + scheduleOnce(ResourceID.fromResource(resource), delay); + } + + public void scheduleOnce(ResourceID resourceID, long delay) { + if (!isRunning()) { + throw new IllegalStateException("The TimerEventSource is not running"); + } + + if (onceTasks.containsKey(resourceID)) { + cancelOnceSchedule(resourceID); + } + EventProducerTimeTask task = new EventProducerTimeTask(resourceID); + onceTasks.put(resourceID, task); + timer.schedule(task, delay); + } + + @Override + public void onResourceDeleted(R resource) { + cancelOnceSchedule(ResourceID.fromResource(resource)); + } + + public void cancelOnceSchedule(ResourceID customResourceUid) { + TimerTask timerTask = onceTasks.remove(customResourceUid); + if (timerTask != null) { + timerTask.cancel(); + } + } + + @Override + public void start() { + if (!isRunning()) { + super.start(); + timer = new Timer(true); + } + } + + @Override + public void stop() { + if (isRunning()) { + onceTasks.keySet().forEach(this::cancelOnceSchedule); + timer.cancel(); + super.stop(); + } + } + + @Override + public Set getSecondaryResources(HasMetadata primary) { + return Set.of(); + } + + public class EventProducerTimeTask extends TimerTask { + + protected final ResourceID customResourceUid; + + public EventProducerTimeTask(ResourceID customResourceUid) { + this.customResourceUid = customResourceUid; + } + + @Override + public void run() { + if (isRunning()) { + log.debug("Producing event for custom resource id: {}", customResourceUid); + getEventHandler().handleEvent(new Event(customResourceUid)); + } + } + } +} 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 new file mode 100644 index 0000000000..a8e1c5b466 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java @@ -0,0 +1,90 @@ +package io.javaoperatorsdk.operator.processing.retry; + +import io.javaoperatorsdk.operator.api.config.AnnotationConfigurable; + +public class GenericRetry implements Retry, AnnotationConfigurable { + 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; + + public static final Retry DEFAULT = new GenericRetry(); + + public static GenericRetry defaultLimitedExponentialRetry() { + return (GenericRetry) DEFAULT; + } + + public static GenericRetry noRetry() { + return new GenericRetry().setMaxAttempts(0); + } + + public static GenericRetry every10second10TimesRetry() { + return new GenericRetry().withLinearRetry().setMaxAttempts(10).setInitialInterval(10000); + } + + @Override + public GenericRetryExecution initExecution() { + return new GenericRetryExecution(this); + } + + public int getMaxAttempts() { + return maxAttempts; + } + + public GenericRetry setMaxAttempts(int maxRetryAttempts) { + this.maxAttempts = maxRetryAttempts; + return this; + } + + public long getInitialInterval() { + return initialInterval; + } + + public GenericRetry setInitialInterval(long initialInterval) { + this.initialInterval = initialInterval; + return this; + } + + public double getIntervalMultiplier() { + return intervalMultiplier; + } + + public GenericRetry setIntervalMultiplier(double intervalMultiplier) { + this.intervalMultiplier = intervalMultiplier; + return this; + } + + public long getMaxInterval() { + return maxInterval; + } + + public GenericRetry setMaxInterval(long maxInterval) { + this.maxInterval = maxInterval; + return this; + } + + public GenericRetry withoutMaxInterval() { + this.maxInterval = -1; + return this; + } + + public GenericRetry withoutMaxAttempts() { + return this.setMaxAttempts(-1); + } + + public GenericRetry withLinearRetry() { + this.intervalMultiplier = 1; + return this; + } + + @Override + public void initFrom(GradualRetry configuration) { + this.initialInterval = configuration.initialInterval(); + this.maxAttempts = configuration.maxAttempts(); + this.intervalMultiplier = configuration.intervalMultiplier(); + this.maxInterval = + configuration.maxInterval() == GradualRetry.UNSET_VALUE + ? GradualRetry.DEFAULT_MAX_INTERVAL + : configuration.maxInterval(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecution.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecution.java new file mode 100644 index 0000000000..a2c7a9a609 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecution.java @@ -0,0 +1,40 @@ +package io.javaoperatorsdk.operator.processing.retry; + +import java.util.Optional; + +public class GenericRetryExecution implements RetryExecution { + + private final GenericRetry genericRetry; + + private int lastAttemptIndex = 0; + private long currentInterval; + + public GenericRetryExecution(GenericRetry genericRetry) { + this.genericRetry = genericRetry; + this.currentInterval = genericRetry.getInitialInterval(); + } + + public Optional nextDelay() { + if (genericRetry.getMaxAttempts() > -1 && lastAttemptIndex >= genericRetry.getMaxAttempts()) { + return Optional.empty(); + } + if (lastAttemptIndex > 1) { + currentInterval = (long) (currentInterval * genericRetry.getIntervalMultiplier()); + if (genericRetry.getMaxInterval() > -1 && currentInterval > genericRetry.getMaxInterval()) { + currentInterval = genericRetry.getMaxInterval(); + } + } + lastAttemptIndex++; + return Optional.of(currentInterval); + } + + @Override + public boolean isLastAttempt() { + return genericRetry.getMaxAttempts() > -1 && lastAttemptIndex >= genericRetry.getMaxAttempts(); + } + + @Override + public int getAttemptCount() { + return lastAttemptIndex; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GradualRetry.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GradualRetry.java new file mode 100644 index 0000000000..9eb4063e66 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GradualRetry.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.processing.retry; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface GradualRetry { + + int DEFAULT_MAX_ATTEMPTS = 5; + long DEFAULT_INITIAL_INTERVAL = 2000L; + double DEFAULT_MULTIPLIER = 1.5D; + + long DEFAULT_MAX_INTERVAL = + (long) + (GradualRetry.DEFAULT_INITIAL_INTERVAL + * Math.pow(GradualRetry.DEFAULT_MULTIPLIER, GradualRetry.DEFAULT_MAX_ATTEMPTS)); + + long UNSET_VALUE = Long.MAX_VALUE; + + int maxAttempts() default DEFAULT_MAX_ATTEMPTS; + + long initialInterval() default DEFAULT_INITIAL_INTERVAL; + + double intervalMultiplier() default DEFAULT_MULTIPLIER; + + long maxInterval() default UNSET_VALUE; +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/Retry.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/Retry.java new file mode 100644 index 0000000000..fb36aaa92c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/Retry.java @@ -0,0 +1,7 @@ +package io.javaoperatorsdk.operator.processing.retry; + +@FunctionalInterface +public interface Retry { + + RetryExecution initExecution(); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/RetryExecution.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/RetryExecution.java new file mode 100644 index 0000000000..78f1b420c5 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/RetryExecution.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.processing.retry; + +import java.util.Optional; + +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; + +public interface RetryExecution extends RetryInfo { + + /** + * @return the time to wait until the next execution in milliseconds + */ + Optional nextDelay(); +} diff --git a/operator-framework-core/src/main/resources/META-INF/beans.xml b/operator-framework-core/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ControllerManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ControllerManagerTest.java new file mode 100644 index 0000000000..f6b2837554 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ControllerManagerTest.java @@ -0,0 +1,61 @@ +package io.javaoperatorsdk.operator; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ResolvedControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.sample.simple.TestCustomReconciler; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static io.javaoperatorsdk.operator.ControllerManager.CANNOT_REGISTER_MULTIPLE_CONTROLLERS_WITH_SAME_NAME_MESSAGE; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ControllerManagerTest { + + @Test + void addingReconcilerWithSameNameShouldNotWork() { + final var controllerConfiguration = + new TestControllerConfiguration<>(new TestCustomReconciler(null), TestCustomResource.class); + var controller = + new Controller<>( + controllerConfiguration.reconciler, + controllerConfiguration, + MockKubernetesClient.client(controllerConfiguration.getResourceClass())); + ConfigurationService configurationService = new BaseConfigurationService(); + final var controllerManager = + new ControllerManager(configurationService.getExecutorServiceManager()); + controllerManager.add(controller); + + var ex = + assertThrows( + OperatorException.class, + () -> { + controllerManager.add(controller); + }); + assertTrue( + ex.getMessage().contains(CANNOT_REGISTER_MULTIPLE_CONTROLLERS_WITH_SAME_NAME_MESSAGE)); + } + + private static class TestControllerConfiguration + extends ResolvedControllerConfiguration { + private final Reconciler reconciler; + + public TestControllerConfiguration(Reconciler reconciler, Class crClass) { + super( + crClass, + getControllerName(reconciler), + reconciler.getClass(), + new BaseConfigurationService()); + this.reconciler = reconciler; + } + + static String getControllerName(Reconciler controller) { + return controller.getClass().getSimpleName() + "Controller"; + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/CustomResourceUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/CustomResourceUtilsTest.java new file mode 100644 index 0000000000..3b05d81477 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/CustomResourceUtilsTest.java @@ -0,0 +1,41 @@ +package io.javaoperatorsdk.operator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.sample.simple.NamespacedTestCustomResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +public class CustomResourceUtilsTest { + @Test + public void assertClusterCustomResourceIsCluster() { + var crd = TestUtils.testCRD("Cluster"); + + CustomResourceUtils.assertCustomResource(TestCustomResource.class, crd); + } + + @Test + public void assertClusterCustomResourceNotNamespaced() { + var crd = TestUtils.testCRD("Cluster"); + + Assertions.assertThrows( + OperatorException.class, + () -> CustomResourceUtils.assertCustomResource(NamespacedTestCustomResource.class, crd)); + } + + @Test + public void assertNamespacedCustomResourceIsNamespaced() { + var crd = TestUtils.testCRD("Namespaced"); + + CustomResourceUtils.assertCustomResource(NamespacedTestCustomResource.class, crd); + } + + @Test + public void assertNamespacedCustomResourceNotCluster() { + var crd = TestUtils.testCRD("Namespaced"); + + Assertions.assertThrows( + OperatorException.class, + () -> CustomResourceUtils.assertCustomResource(TestCustomResource.class, crd)); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/LeaderElectionManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/LeaderElectionManagerTest.java new file mode 100644 index 0000000000..2154c57452 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/LeaderElectionManagerTest.java @@ -0,0 +1,126 @@ +package io.javaoperatorsdk.operator; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import io.fabric8.kubernetes.api.model.authorization.v1.ResourceRule; +import io.fabric8.kubernetes.api.model.authorization.v1.SelfSubjectRulesReview; +import io.fabric8.kubernetes.api.model.authorization.v1.SubjectRulesReviewStatus; +import io.fabric8.kubernetes.api.model.coordination.v1.Lease; +import io.fabric8.kubernetes.client.Config; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration; + +import static io.fabric8.kubernetes.client.Config.KUBERNETES_AUTH_TRYKUBECONFIG_SYSTEM_PROPERTY; +import static io.fabric8.kubernetes.client.Config.KUBERNETES_NAMESPACE_FILE; +import static io.javaoperatorsdk.operator.LeaderElectionManager.COORDINATION_GROUP; +import static io.javaoperatorsdk.operator.LeaderElectionManager.LEASES_RESOURCE; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LeaderElectionManagerTest { + + private LeaderElectionManager leaderElectionManager(Object selfSubjectReview) { + ControllerManager controllerManager = mock(ControllerManager.class); + final var kubernetesClient = MockKubernetesClient.client(Lease.class, selfSubjectReview); + when(kubernetesClient.getConfiguration()).thenReturn(Config.autoConfigure(null)); + var configurationService = + ConfigurationService.newOverriddenConfigurationService( + o -> + o.withLeaderElectionConfiguration(new LeaderElectionConfiguration("test")) + .withKubernetesClient(kubernetesClient)); + return new LeaderElectionManager(controllerManager, configurationService); + } + + @AfterEach + void tearDown() { + System.getProperties().remove(KUBERNETES_NAMESPACE_FILE); + System.getProperties().remove(KUBERNETES_AUTH_TRYKUBECONFIG_SYSTEM_PROPERTY); + } + + @Test + void testInitInferLeaseNamespace(@TempDir Path tempDir) throws IOException { + var namespace = "foo"; + var namespacePath = tempDir.resolve("namespace"); + Files.writeString(namespacePath, namespace); + + System.setProperty(KUBERNETES_AUTH_TRYKUBECONFIG_SYSTEM_PROPERTY, "false"); + System.setProperty(KUBERNETES_NAMESPACE_FILE, namespacePath.toString()); + + final var leaderElectionManager = leaderElectionManager(null); + leaderElectionManager.start(); + assertTrue(leaderElectionManager.isLeaderElectionEnabled()); + } + + @Test + void testFailedToInitInferLeaseNamespace() { + System.setProperty(KUBERNETES_AUTH_TRYKUBECONFIG_SYSTEM_PROPERTY, "false"); + final var leaderElectionManager = leaderElectionManager(null); + assertThrows(IllegalArgumentException.class, leaderElectionManager::start); + } + + @Test + void testInitPermissionsMultipleRulesWithResourceName(@TempDir Path tempDir) throws IOException { + var namespace = "foo"; + var namespacePath = tempDir.resolve("namespace"); + Files.writeString(namespacePath, namespace); + + System.setProperty(KUBERNETES_AUTH_TRYKUBECONFIG_SYSTEM_PROPERTY, "false"); + System.setProperty(KUBERNETES_NAMESPACE_FILE, namespacePath.toString()); + + SelfSubjectRulesReview review = new SelfSubjectRulesReview(); + review.setStatus(new SubjectRulesReviewStatus()); + var resourceRule1 = new ResourceRule(); + resourceRule1.setApiGroups(Arrays.asList(COORDINATION_GROUP)); + resourceRule1.setResources(Arrays.asList(LEASES_RESOURCE)); + resourceRule1.setResourceNames(Arrays.asList("test")); + resourceRule1.setVerbs(Arrays.asList("get", "update")); + var resourceRule2 = new ResourceRule(); + resourceRule2.setApiGroups(Arrays.asList(COORDINATION_GROUP)); + resourceRule2.setResources(Arrays.asList(LEASES_RESOURCE)); + resourceRule2.setVerbs(Arrays.asList("create")); + review.getStatus().setResourceRules(Arrays.asList(resourceRule1, resourceRule2)); + + final var leaderElectionManager = leaderElectionManager(review); + leaderElectionManager.start(); + assertTrue(leaderElectionManager.isLeaderElectionEnabled()); + } + + @Test + void testFailedToInitMissingPermission(@TempDir Path tempDir) throws IOException { + var namespace = "foo"; + var namespacePath = tempDir.resolve("namespace"); + Files.writeString(namespacePath, namespace); + + System.setProperty(KUBERNETES_AUTH_TRYKUBECONFIG_SYSTEM_PROPERTY, "false"); + System.setProperty(KUBERNETES_NAMESPACE_FILE, namespacePath.toString()); + + SelfSubjectRulesReview review = new SelfSubjectRulesReview(); + review.setStatus(new SubjectRulesReviewStatus()); + var resourceRule1 = new ResourceRule(); + resourceRule1.setApiGroups(Arrays.asList(COORDINATION_GROUP)); + resourceRule1.setResources(Arrays.asList(LEASES_RESOURCE)); + resourceRule1.setVerbs(Arrays.asList("get")); + var resourceRule2 = new ResourceRule(); + resourceRule2.setApiGroups(Arrays.asList(COORDINATION_GROUP)); + resourceRule2.setResources(Arrays.asList(LEASES_RESOURCE)); + resourceRule2.setVerbs(Arrays.asList("update")); + var resourceRule3 = new ResourceRule(); + resourceRule3.setApiGroups(Arrays.asList(COORDINATION_GROUP)); + resourceRule3.setResources(Arrays.asList(LEASES_RESOURCE)); + resourceRule3.setResourceNames(Arrays.asList("some-other-lease")); + resourceRule3.setVerbs(Arrays.asList("create")); + review.getStatus().setResourceRules(Arrays.asList(resourceRule1, resourceRule2, resourceRule3)); + + final var leaderElectionManager = leaderElectionManager(review); + assertThrows(OperatorException.class, leaderElectionManager::start); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/MockKubernetesClient.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/MockKubernetesClient.java new file mode 100644 index 0000000000..e4cafbdb72 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/MockKubernetesClient.java @@ -0,0 +1,138 @@ +package io.javaoperatorsdk.operator; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.function.Consumer; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.KubernetesResourceList; +import io.fabric8.kubernetes.api.model.authorization.v1.ResourceRule; +import io.fabric8.kubernetes.api.model.authorization.v1.SelfSubjectRulesReview; +import io.fabric8.kubernetes.api.model.authorization.v1.SubjectRulesReviewStatus; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.V1ApiextensionAPIGroupDSL; +import io.fabric8.kubernetes.client.dsl.AnyNamespaceOperation; +import io.fabric8.kubernetes.client.dsl.ApiextensionsAPIGroupDSL; +import io.fabric8.kubernetes.client.dsl.FilterWatchListDeletable; +import io.fabric8.kubernetes.client.dsl.Informable; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.NamespaceableResource; +import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.extended.leaderelection.LeaderElectorBuilder; +import io.fabric8.kubernetes.client.informers.SharedIndexInformer; +import io.fabric8.kubernetes.client.informers.cache.Indexer; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; + +import static io.javaoperatorsdk.operator.LeaderElectionManager.COORDINATION_GROUP; +import static io.javaoperatorsdk.operator.LeaderElectionManager.LEASES_RESOURCE; +import static io.javaoperatorsdk.operator.LeaderElectionManager.UNIVERSAL_VALUE; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.nullable; +import static org.mockito.Mockito.when; + +public class MockKubernetesClient { + + public static KubernetesClient client(Class clazz) { + return client(clazz, null, null); + } + + public static KubernetesClient client( + Class clazz, Object selfSubjectReview) { + return client(clazz, null, selfSubjectReview); + } + + public static KubernetesClient client( + Class clazz, Consumer informerRunBehavior) { + return client(clazz, informerRunBehavior, null); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + public static KubernetesClient client( + Class clazz, Consumer informerRunBehavior, Object selfSubjectReview) { + final var client = mock(KubernetesClient.class); + MixedOperation, Resource> resources = + mock(MixedOperation.class); + NonNamespaceOperation, Resource> nonNamespaceOperation = + mock(NonNamespaceOperation.class); + AnyNamespaceOperation, Resource> inAnyNamespace = + mock(AnyNamespaceOperation.class); + FilterWatchListDeletable, Resource> filterable = + mock(FilterWatchListDeletable.class); + when(resources.inNamespace(anyString())).thenReturn(nonNamespaceOperation); + when(nonNamespaceOperation.withLabelSelector(nullable(String.class))).thenReturn(filterable); + when(resources.inAnyNamespace()).thenReturn(inAnyNamespace); + when(inAnyNamespace.withLabelSelector(nullable(String.class))).thenReturn(filterable); + SharedIndexInformer informer = mock(SharedIndexInformer.class); + CompletableFuture informerStartRes = new CompletableFuture<>(); + informerStartRes.complete(null); + when(informer.start()).thenReturn(informerStartRes); + CompletableFuture stopped = new CompletableFuture<>(); + when(informer.stopped()).thenReturn(stopped); + when(informer.getApiTypeClass()).thenReturn(clazz); + if (informerRunBehavior != null) { + doAnswer( + invocation -> { + try { + informerRunBehavior.accept(null); + } catch (Exception e) { + stopped.completeExceptionally(e); + } + return stopped; + }) + .when(informer) + .start(); + } + doAnswer(invocation -> null).when(informer).stop(); + Indexer mockIndexer = mock(Indexer.class); + + when(informer.getIndexer()).thenReturn(mockIndexer); + + when(filterable.runnableInformer(anyLong())).thenReturn(informer); + + Informable informable = mock(Informable.class); + when(filterable.withLimit(anyLong())).thenReturn(informable); + when(informable.runnableInformer(anyLong())).thenReturn(informer); + + when(client.resources(clazz)).thenReturn(resources); + when(client.leaderElector()) + .thenReturn(new LeaderElectorBuilder(client, Executors.newSingleThreadExecutor())); + var selfSubjectResourceResourceMock = mock(NamespaceableResource.class); + when(client.resource(any(SelfSubjectRulesReview.class))) + .thenReturn(selfSubjectResourceResourceMock); + when(selfSubjectResourceResourceMock.create()) + .thenReturn( + Optional.ofNullable(selfSubjectReview) + .orElseGet(MockKubernetesClient::allowSelfSubjectReview)); + + final var apiGroupDSL = mock(ApiextensionsAPIGroupDSL.class); + when(client.apiextensions()).thenReturn(apiGroupDSL); + final var v1 = mock(V1ApiextensionAPIGroupDSL.class); + when(apiGroupDSL.v1()).thenReturn(v1); + final var operation = mock(NonNamespaceOperation.class); + when(v1.customResourceDefinitions()).thenReturn(operation); + when(operation.withName(any())).thenReturn(mock(Resource.class)); + + final var serialization = new KubernetesSerialization(); + when(client.getKubernetesSerialization()).thenReturn(serialization); + + return client; + } + + private static Object allowSelfSubjectReview() { + SelfSubjectRulesReview review = new SelfSubjectRulesReview(); + review.setStatus(new SubjectRulesReviewStatus()); + var resourceRule = new ResourceRule(); + resourceRule.setApiGroups(List.of(COORDINATION_GROUP)); + resourceRule.setResources(List.of(LEASES_RESOURCE)); + resourceRule.setVerbs(List.of(UNIVERSAL_VALUE)); + review.getStatus().setResourceRules(List.of(resourceRule)); + return review; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorTest.java new file mode 100644 index 0000000000..39fc98f6b0 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorTest.java @@ -0,0 +1,86 @@ +package io.javaoperatorsdk.operator; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +import static org.junit.jupiter.api.Assertions.*; + +@SuppressWarnings("rawtypes") +class OperatorTest { + @Test + void shouldBePossibleToRetrieveNumberOfRegisteredControllers() { + final var operator = new Operator(); + assertEquals(0, operator.getRegisteredControllersNumber()); + + operator.register(new FooReconciler()); + assertEquals(1, operator.getRegisteredControllersNumber()); + } + + @Test + void shouldBePossibleToRetrieveRegisteredControllerByName() { + final var operator = new Operator(); + final var reconciler = new FooReconciler(); + final var name = ReconcilerUtils.getNameFor(reconciler); + + var registeredControllers = operator.getRegisteredControllers(); + assertTrue(operator.getRegisteredController(name).isEmpty()); + assertTrue(registeredControllers.isEmpty()); + + operator.register(reconciler); + final var maybeController = operator.getRegisteredController(name); + assertTrue(maybeController.isPresent()); + assertEquals(name, maybeController.map(rc -> rc.getConfiguration().getName()).orElseThrow()); + + registeredControllers = operator.getRegisteredControllers(); + assertEquals(1, registeredControllers.size()); + assertEquals(maybeController.get(), registeredControllers.stream().findFirst().orElseThrow()); + } + + @Test + void shouldThrowExceptionIf() { + final var operator = new OperatorExtension(); + assertNotNull(operator); + operator.setConfigurationService(ConfigurationService.newOverriddenConfigurationService(null)); + assertNotNull(operator.getConfigurationService()); + + // should fail because the implementation is not providing a valid configuration service when + // constructing the operator + assertThrows( + IllegalStateException.class, + () -> new OperatorExtension(MockKubernetesClient.client(ConfigMap.class))); + } + + private static class FooReconciler implements Reconciler { + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return UpdateControl.noUpdate(); + } + } + + private static class OperatorExtension extends Operator { + public OperatorExtension() {} + + public OperatorExtension(KubernetesClient client) { + super(client); + } + + /** + * Overridden to mimic deferred initialization (or rather the fact that we don't want to do that + * processing at this time so return null). + */ + @Override + protected ConfigurationService initConfigurationService( + KubernetesClient client, Consumer overrider) { + return null; + } + } +} 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 new file mode 100644 index 0000000000..ad77196068 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java @@ -0,0 +1,235 @@ +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.api.model.apps.DeploymentStatus; +import io.fabric8.kubernetes.api.model.rbac.ClusterRole; +import io.fabric8.kubernetes.api.model.rbac.ClusterRoleBuilder; +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 setsStatusWithReflection() { + Deployment deployment = new Deployment(); + DeploymentStatus status = new DeploymentStatus(); + status.setReplicas(2); + + ReconcilerUtils.setStatus(deployment, status); + + assertThat(deployment.getStatus().getReplicas()).isEqualTo(2); + } + + @Test + void getsStatusWithReflection() { + Deployment deployment = new Deployment(); + DeploymentStatus status = new DeploymentStatus(); + status.setReplicas(2); + deployment.setStatus(status); + + var res = ReconcilerUtils.getStatus(deployment); + + assertThat(((DeploymentStatus) res).getReplicas()).isEqualTo(2); + } + + @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))); + } + + @Test + void checksIfOwnerReferenceCanBeAdded() { + assertThrows( + OperatorException.class, + () -> + ReconcilerUtils.checkIfCanAddOwnerReference( + namespacedResource(), namespacedResourceFromOtherNamespace())); + + assertThrows( + OperatorException.class, + () -> + ReconcilerUtils.checkIfCanAddOwnerReference( + namespacedResource(), clusterScopedResource())); + + assertDoesNotThrow( + () -> { + ReconcilerUtils.checkIfCanAddOwnerReference( + clusterScopedResource(), clusterScopedResource()); + ReconcilerUtils.checkIfCanAddOwnerReference(namespacedResource(), namespacedResource()); + }); + } + + private ClusterRole clusterScopedResource() { + return new ClusterRoleBuilder().withMetadata(new ObjectMetaBuilder().build()).build(); + } + + private ConfigMap namespacedResource() { + return new ConfigMapBuilder() + .withMetadata(new ObjectMetaBuilder().withNamespace("testns1").build()) + .build(); + } + + private ConfigMap namespacedResourceFromOtherNamespace() { + return new ConfigMapBuilder() + .withMetadata(new ObjectMetaBuilder().withNamespace("testns2").build()) + .build(); + } + + @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 new file mode 100644 index 0000000000..afef9e6703 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/TestUtils.java @@ -0,0 +1,56 @@ +package io.javaoperatorsdk.operator; + +import java.util.HashMap; +import java.util.UUID; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinitionBuilder; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResourceSpec; + +public class TestUtils { + + public static TestCustomResource testCustomResource() { + return testCustomResource(new ResourceID(UUID.randomUUID().toString(), "test")); + } + + public static CustomResourceDefinition testCRD(String scope) { + return new CustomResourceDefinitionBuilder() + .editOrNewSpec() + .withScope(scope) + .and() + .editOrNewMetadata() + .withName("test.operator.javaoperatorsdk.io") + .and() + .build(); + } + + public static TestCustomResource testCustomResource1() { + return testCustomResource(new ResourceID("test1", "default")); + } + + public static TestCustomResource testCustomResource(ResourceID id) { + TestCustomResource resource = new TestCustomResource(); + resource.setMetadata( + new ObjectMetaBuilder() + .withName(id.getName()) + .withResourceVersion("1") + .withGeneration(1L) + .withNamespace(id.getNamespace().orElse(null)) + .build()); + resource.getMetadata().setAnnotations(new HashMap<>()); + resource.setSpec(new TestCustomResourceSpec()); + resource.getSpec().setConfigMapName("test-config-map"); + resource.getSpec().setKey("test-key"); + resource.getSpec().setValue("test-value"); + return resource; + } + + public static T markForDeletion(T customResource) { + customResource.getMetadata().setDeletionTimestamp("2019-8-10"); + return customResource; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/DeleteControlTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/DeleteControlTest.java new file mode 100644 index 0000000000..66137ed790 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/DeleteControlTest.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.api; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; + +class DeleteControlTest { + + @Test + void cannotReScheduleForDefaultDelete() { + Assertions.assertThrows( + IllegalStateException.class, () -> DeleteControl.defaultDelete().rescheduleAfter(1000L)); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.java new file mode 100644 index 0000000000..4f30458d68 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.java @@ -0,0 +1,106 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadPoolExecutor; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class ConfigurationServiceOverriderTest { + + private static final Metrics METRICS = new Metrics() {}; + + private static final LeaderElectionConfiguration LEADER_ELECTION_CONFIGURATION = + new LeaderElectionConfiguration("foo", "fooNS"); + + private static final Cloner CLONER = + new Cloner() { + @Override + public R clone(R object) { + return null; + } + }; + + final BaseConfigurationService config = + new BaseConfigurationService(null) { + @Override + public boolean checkCRDAndValidateLocalModel() { + return false; + } + + @Override + public Metrics getMetrics() { + return METRICS; + } + + @Override + public Cloner getResourceCloner() { + return CLONER; + } + + @Override + public Optional getLeaderElectionConfiguration() { + return Optional.of(LEADER_ELECTION_CONFIGURATION); + } + }; + + @Test + void overrideShouldWork() { + + final var overridden = + new ConfigurationServiceOverrider(config) + .checkingCRDAndValidateLocalModel(true) + .withExecutorService(Executors.newSingleThreadExecutor()) + .withWorkflowExecutorService(Executors.newFixedThreadPool(4)) + .withCloseClientOnStop(false) + .withResourceCloner( + new Cloner() { + @Override + public R clone(R object) { + return null; + } + }) + .withConcurrentReconciliationThreads(25) + .withMetrics(new Metrics() {}) + .withLeaderElectionConfiguration( + new LeaderElectionConfiguration("newLease", "newLeaseNS")) + .withInformerStoppedHandler((informer, ex) -> {}) + .withReconciliationTerminationTimeout(Duration.ofSeconds(30)) + .build(); + + assertNotEquals(config.closeClientOnStop(), overridden.closeClientOnStop()); + assertNotEquals( + config.checkCRDAndValidateLocalModel(), overridden.checkCRDAndValidateLocalModel()); + assertNotEquals( + config.concurrentReconciliationThreads(), overridden.concurrentReconciliationThreads()); + assertNotEquals(config.getExecutorService(), overridden.getExecutorService()); + assertNotEquals(config.getWorkflowExecutorService(), overridden.getWorkflowExecutorService()); + assertNotEquals(config.getMetrics(), overridden.getMetrics()); + assertNotEquals( + config.getLeaderElectionConfiguration(), overridden.getLeaderElectionConfiguration()); + assertNotEquals( + config.getInformerStoppedHandler(), overridden.getLeaderElectionConfiguration()); + assertNotEquals( + config.reconciliationTerminationTimeout(), overridden.reconciliationTerminationTimeout()); + } + + @Test + void threadCountConfiguredProperly() { + final var overridden = + new ConfigurationServiceOverrider(config) + .withConcurrentReconciliationThreads(13) + .withConcurrentWorkflowExecutorThreads(14) + .build(); + assertThat(((ThreadPoolExecutor) overridden.getExecutorService()).getMaximumPoolSize()) + .isEqualTo(13); + assertThat(((ThreadPoolExecutor) overridden.getWorkflowExecutorService()).getMaximumPoolSize()) + .isEqualTo(14); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverriderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverriderTest.java new file mode 100644 index 0000000000..837ad7463a --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverriderTest.java @@ -0,0 +1,453 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.util.Optional; +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.client.informers.cache.BasicItemStore; +import io.fabric8.kubernetes.client.informers.cache.Cache; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceConfigurationResolver; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +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.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ConfiguredDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfigBuilder; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +import static io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration.inheritsNamespacesFromController; +import static org.junit.jupiter.api.Assertions.*; + +class ControllerConfigurationOverriderTest { + private final BaseConfigurationService configurationService = new BaseConfigurationService(); + + @SuppressWarnings("unchecked") + private static Object extractDependentKubernetesResourceConfig( + io.javaoperatorsdk.operator.api.config.ControllerConfiguration configuration, int index) { + final var spec = + configuration.getWorkflowSpec().orElseThrow().getDependentResourceSpecs().get(index); + return configuration.getConfigurationFor(spec); + } + + @BeforeEach + void clearStaticState() { + DependentResourceConfigurationResolver.clear(); + } + + @Test + void overridingNSShouldPreserveUntouchedDependents() { + var configuration = createConfiguration(new NamedDependentReconciler()); + + // check that we have the proper number of dependent configs + var dependentResources = + configuration.getWorkflowSpec().orElseThrow().getDependentResourceSpecs(); + assertEquals(2, dependentResources.size()); + + // override the NS + final var namespace = "some-ns"; + final var externalDRName = + DependentResource.defaultNameFor(NamedDependentReconciler.ExternalDependentResource.class); + final var stringConfig = "some String configuration"; + configuration = + ControllerConfigurationOverrider.override(configuration) + .settingNamespace(namespace) + .replacingNamedDependentResourceConfig(externalDRName, stringConfig) + .build(); + assertEquals(Set.of(namespace), configuration.getInformerConfig().getNamespaces()); + + // check that we still have the proper number of dependent configs + dependentResources = configuration.getWorkflowSpec().orElseThrow().getDependentResourceSpecs(); + assertEquals(2, dependentResources.size()); + final var resourceConfig = extractDependentKubernetesResourceConfig(configuration, 1); + assertEquals(stringConfig, resourceConfig); + } + + @SuppressWarnings({"rawtypes"}) + private KubernetesDependentResourceConfig extractFirstDependentKubernetesResourceConfig( + io.javaoperatorsdk.operator.api.config.ControllerConfiguration configuration) { + return (KubernetesDependentResourceConfig) + extractDependentKubernetesResourceConfig(configuration, 0); + } + + private io.javaoperatorsdk.operator.api.config.ControllerConfiguration createConfiguration( + Reconciler reconciler) { + return configurationService.configFor(reconciler); + } + + @Test + void overridingNamespacesShouldNotThrowNPE() { + var configuration = createConfiguration(new NullReconciler()); + configuration = + ControllerConfigurationOverrider.override(configuration).settingNamespaces().build(); + assertTrue(configuration.getInformerConfig().watchAllNamespaces()); + } + + private static class NullReconciler implements Reconciler { + @Override + public UpdateControl reconcile(HasMetadata resource, Context context) + throws Exception { + return null; + } + } + + @Test + void overridingNamespacesShouldWork() { + var configuration = createConfiguration(new WatchCurrentReconciler()); + var informerConfig = configuration.getInformerConfig(); + assertEquals(Set.of("foo"), informerConfig.getNamespaces()); + assertFalse(informerConfig.watchAllNamespaces()); + assertFalse(informerConfig.watchCurrentNamespace()); + + configuration = + ControllerConfigurationOverrider.override(configuration) + .addingNamespaces("foo", "bar") + .build(); + informerConfig = configuration.getInformerConfig(); + assertEquals(Set.of("foo", "bar"), informerConfig.getNamespaces()); + assertFalse(informerConfig.watchAllNamespaces()); + assertFalse(informerConfig.watchCurrentNamespace()); + + configuration = + ControllerConfigurationOverrider.override(configuration).removingNamespaces("bar").build(); + informerConfig = configuration.getInformerConfig(); + assertEquals(Set.of("foo"), informerConfig.getNamespaces()); + assertFalse(informerConfig.watchAllNamespaces()); + assertFalse(informerConfig.watchCurrentNamespace()); + + configuration = + ControllerConfigurationOverrider.override(configuration).removingNamespaces("foo").build(); + informerConfig = configuration.getInformerConfig(); + assertTrue(informerConfig.watchAllNamespaces()); + assertFalse(informerConfig.watchCurrentNamespace()); + + configuration = + ControllerConfigurationOverrider.override(configuration).settingNamespace("foo").build(); + informerConfig = configuration.getInformerConfig(); + assertFalse(informerConfig.watchAllNamespaces()); + assertFalse(informerConfig.watchCurrentNamespace()); + assertEquals(Set.of("foo"), informerConfig.getNamespaces()); + + configuration = + ControllerConfigurationOverrider.override(configuration) + .watchingOnlyCurrentNamespace() + .build(); + informerConfig = configuration.getInformerConfig(); + assertFalse(informerConfig.watchAllNamespaces()); + assertTrue(informerConfig.watchCurrentNamespace()); + + configuration = + ControllerConfigurationOverrider.override(configuration).watchingAllNamespaces().build(); + informerConfig = configuration.getInformerConfig(); + assertTrue(informerConfig.watchAllNamespaces()); + assertFalse(informerConfig.watchCurrentNamespace()); + } + + @Test + void itemStorePreserved() { + var configuration = createConfiguration(new WatchCurrentReconciler()); + + configuration = ControllerConfigurationOverrider.override(configuration).build(); + + assertNotNull(configuration.getInformerConfig().getItemStore()); + } + + @Test + void configuredDependentShouldNotChangeOnParentOverrideEvenWhenInitialConfigIsSame() { + var configuration = createConfiguration(new OverriddenNSOnDepReconciler()); + // retrieve the config for the first (and unique) dependent + var kubeDependentConfig = extractFirstDependentKubernetesResourceConfig(configuration); + + // override the parent NS to match the dependent's + configuration = + ControllerConfigurationOverrider.override(configuration) + .settingNamespace(OverriddenNSDependent.DEP_NS) + .build(); + assertEquals( + Set.of(OverriddenNSDependent.DEP_NS), configuration.getInformerConfig().getNamespaces()); + + // check that the DependentResource inherits has its own configured NS + assertEquals( + Set.of(OverriddenNSDependent.DEP_NS), kubeDependentConfig.informerConfig().getNamespaces()); + + // override the parent's NS + final var newNS = "bar"; + configuration = + ControllerConfigurationOverrider.override(configuration).settingNamespace(newNS).build(); + + // check that dependent config is still using its own NS + kubeDependentConfig = extractFirstDependentKubernetesResourceConfig(configuration); + assertEquals( + Set.of(OverriddenNSDependent.DEP_NS), kubeDependentConfig.informerConfig().getNamespaces()); + } + + @SuppressWarnings("unchecked") + @Test + void dependentShouldWatchAllNamespacesIfParentDoesAsWell() { + var configuration = createConfiguration(new WatchAllNamespacesReconciler()); + // retrieve the config for the first (and unique) dependent + var config = extractFirstDependentKubernetesResourceConfig(configuration); + + // check that the DependentResource inherits the controller's configuration if applicable + var informerConfig = config.informerConfig(); + assertTrue(inheritsNamespacesFromController(informerConfig.getNamespaces())); + } + + @SuppressWarnings("unchecked") + @Test + void shouldBePossibleToForceDependentToWatchAllNamespaces() { + var configuration = createConfiguration(new DependentWatchesAllNSReconciler()); + // retrieve the config for the first (and unique) dependent + var config = extractFirstDependentKubernetesResourceConfig(configuration); + + // check that the DependentResource inherits the controller's configuration if applicable + assertTrue(InformerConfiguration.allNamespacesWatched(config.informerConfig().getNamespaces())); + + // override the NS + final var newNS = "bar"; + configuration = + ControllerConfigurationOverrider.override(configuration).settingNamespace(newNS).build(); + + // check that dependent config is still configured to watch all NS + config = extractFirstDependentKubernetesResourceConfig(configuration); + assertTrue(InformerConfiguration.allNamespacesWatched(config.informerConfig().getNamespaces())); + } + + @Test + void overridingNamespacesShouldBePropagatedToDependentsWithDefaultConfig() { + var configuration = createConfiguration(new OneDepReconciler()); + // retrieve the config for the first (and unique) dependent + var config = extractFirstDependentKubernetesResourceConfig(configuration); + + // check that the DependentResource inherits the controller's configuration if applicable + assertEquals(1, config.informerConfig().getNamespaces().size()); + } + + @Test + void alreadyOverriddenDependentNamespacesShouldNotBePropagated() { + var configuration = createConfiguration(new OverriddenNSOnDepReconciler()); + // retrieve the config for the first (and unique) dependent + var config = extractFirstDependentKubernetesResourceConfig(configuration); + + // DependentResource has its own NS + assertEquals(Set.of(OverriddenNSDependent.DEP_NS), config.informerConfig().getNamespaces()); + + // override the NS + final var newNS = "bar"; + configuration = + ControllerConfigurationOverrider.override(configuration).settingNamespace(newNS).build(); + + // check that dependent config is still using its own NS + config = extractFirstDependentKubernetesResourceConfig(configuration); + assertEquals(Set.of(OverriddenNSDependent.DEP_NS), config.informerConfig().getNamespaces()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void replaceNamedDependentResourceConfigShouldWork() { + var configuration = createConfiguration(new OneDepReconciler()); + var dependents = configuration.getWorkflowSpec().orElseThrow().getDependentResourceSpecs(); + assertFalse(dependents.isEmpty()); + assertEquals(1, dependents.size()); + + final var dependentResourceName = DependentResource.defaultNameFor(ReadOnlyDependent.class); + assertTrue(dependents.stream().anyMatch(dr -> dr.getName().equals(dependentResourceName))); + + var dependentSpec = + dependents.stream() + .filter(dr -> dr.getName().equals(dependentResourceName)) + .findFirst() + .orElseThrow(); + assertEquals(ReadOnlyDependent.class, dependentSpec.getDependentResourceClass()); + var maybeConfig = extractFirstDependentKubernetesResourceConfig(configuration); + assertNotNull(maybeConfig); + assertInstanceOf(KubernetesDependentResourceConfig.class, maybeConfig); + + var config = (KubernetesDependentResourceConfig) maybeConfig; + // check that the DependentResource inherits the controller's configuration if applicable + var informerConfig = config.informerConfig(); + assertEquals(1, informerConfig.getNamespaces().size()); + assertNull(informerConfig.getLabelSelector()); + + // override the namespaces for the dependent resource + final var overriddenNS = "newNS"; + final var labelSelector = "foo=bar"; + final var overridingInformerConfig = + InformerConfiguration.builder(ConfigMap.class) + .withNamespaces(Set.of(overriddenNS)) + .withLabelSelector(labelSelector) + .build(); + final var overridden = + ControllerConfigurationOverrider.override(configuration) + .replacingNamedDependentResourceConfig( + dependentResourceName, + new KubernetesDependentResourceConfigBuilder() + .withKubernetesDependentInformerConfig(overridingInformerConfig) + .build()) + .build(); + dependents = overridden.getWorkflowSpec().orElseThrow().getDependentResourceSpecs(); + dependentSpec = + dependents.stream() + .filter(dr -> dr.getName().equals(dependentResourceName)) + .findFirst() + .orElseThrow(); + config = (KubernetesDependentResourceConfig) overridden.getConfigurationFor(dependentSpec); + informerConfig = config.informerConfig(); + assertEquals(labelSelector, informerConfig.getLabelSelector()); + assertEquals(Set.of(overriddenNS), informerConfig.getNamespaces()); + // check that we still have the proper workflow configuration + assertInstanceOf(TestCondition.class, dependentSpec.getReadyCondition()); + } + + private static class MyItemStore extends BasicItemStore { + + public MyItemStore() { + super(Cache::metaNamespaceKeyFunc); + } + } + + @ControllerConfiguration(informer = @Informer(namespaces = "foo", itemStore = MyItemStore.class)) + private static class WatchCurrentReconciler implements Reconciler { + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + } + + @Workflow(dependents = @Dependent(type = ReadOnlyDependent.class)) + @ControllerConfiguration + private static class WatchAllNamespacesReconciler implements Reconciler { + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + } + + @Workflow(dependents = @Dependent(type = WatchAllNSDependent.class)) + @ControllerConfiguration + private static class DependentWatchesAllNSReconciler implements Reconciler { + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + } + + private static class TestCondition implements Condition { + + @Override + public boolean isMet( + DependentResource dependentResource, + ConfigMap primary, + Context context) { + return true; + } + } + + @Workflow( + dependents = + @Dependent(type = ReadOnlyDependent.class, readyPostcondition = TestCondition.class)) + @ControllerConfiguration(informer = @Informer(namespaces = OneDepReconciler.CONFIGURED_NS)) + private static class OneDepReconciler implements Reconciler { + + private static final String CONFIGURED_NS = "foo"; + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + } + + public static class ReadOnlyDependent extends KubernetesDependentResource + implements GarbageCollected {} + + @KubernetesDependent(informer = @Informer(namespaces = Constants.WATCH_ALL_NAMESPACES)) + public static class WatchAllNSDependent extends KubernetesDependentResource + implements GarbageCollected {} + + @Workflow(dependents = @Dependent(type = OverriddenNSDependent.class)) + @ControllerConfiguration( + informer = @Informer(namespaces = OverriddenNSOnDepReconciler.CONFIGURED_NS)) + public static class OverriddenNSOnDepReconciler implements Reconciler { + + private static final String CONFIGURED_NS = "parentNS"; + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + } + + @KubernetesDependent(informer = @Informer(namespaces = OverriddenNSDependent.DEP_NS)) + public static class OverriddenNSDependent + extends KubernetesDependentResource + implements GarbageCollected { + + private static final String DEP_NS = "dependentNS"; + } + + @Workflow( + dependents = { + @Dependent(type = NamedDependentReconciler.NamedDependentResource.class), + @Dependent(type = NamedDependentReconciler.ExternalDependentResource.class) + }) + @ControllerConfiguration + public static class NamedDependentReconciler implements Reconciler { + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + + private static class NamedDependentResource + extends KubernetesDependentResource + implements GarbageCollected {} + + private static class ExternalDependentResource + implements DependentResource, + ConfiguredDependentResource, + GarbageCollected { + + private String config = "UNSET"; + + @Override + public ReconcileResult reconcile(ConfigMap primary, Context context) { + return null; + } + + @Override + public Class resourceType() { + return Object.class; + } + + @Override + public void configureWith(String config) { + this.config = config; + } + + @Override + public Optional configuration() { + return Optional.of(config); + } + + @Override + public void delete(ConfigMap primary, Context context) {} + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/InformerConfigurationTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/InformerConfigurationTest.java new file mode 100644 index 0000000000..a64b0e027e --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/InformerConfigurationTest.java @@ -0,0 +1,95 @@ +package io.javaoperatorsdk.operator.api.config; + +import java.util.Collections; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Constants; + +import static org.junit.jupiter.api.Assertions.*; + +class InformerConfigurationTest { + + @Test + void allNamespacesWatched() { + assertThrows( + IllegalArgumentException.class, () -> InformerConfiguration.allNamespacesWatched(null)); + assertThrows( + IllegalArgumentException.class, + () -> + InformerConfiguration.allNamespacesWatched( + Set.of(Constants.WATCH_CURRENT_NAMESPACE, Constants.WATCH_ALL_NAMESPACES, "foo"))); + assertThrows( + IllegalArgumentException.class, + () -> InformerConfiguration.allNamespacesWatched(Collections.emptySet())); + assertFalse(InformerConfiguration.allNamespacesWatched(Set.of("foo", "bar"))); + assertTrue(InformerConfiguration.allNamespacesWatched(Set.of(Constants.WATCH_ALL_NAMESPACES))); + assertFalse(InformerConfiguration.allNamespacesWatched(Set.of("foo"))); + assertFalse( + InformerConfiguration.allNamespacesWatched(Set.of(Constants.WATCH_CURRENT_NAMESPACE))); + } + + @Test + void currentNamespaceWatched() { + assertThrows( + IllegalArgumentException.class, () -> InformerConfiguration.currentNamespaceWatched(null)); + assertThrows( + IllegalArgumentException.class, + () -> + InformerConfiguration.currentNamespaceWatched( + Set.of(Constants.WATCH_CURRENT_NAMESPACE, Constants.WATCH_ALL_NAMESPACES, "foo"))); + assertThrows( + IllegalArgumentException.class, + () -> InformerConfiguration.currentNamespaceWatched(Collections.emptySet())); + assertFalse(InformerConfiguration.currentNamespaceWatched(Set.of("foo", "bar"))); + assertFalse( + InformerConfiguration.currentNamespaceWatched(Set.of(Constants.WATCH_ALL_NAMESPACES))); + assertFalse(InformerConfiguration.currentNamespaceWatched(Set.of("foo"))); + assertTrue( + InformerConfiguration.currentNamespaceWatched(Set.of(Constants.WATCH_CURRENT_NAMESPACE))); + } + + @Test + void nullLabelSelectorByDefault() { + final var informerConfig = InformerConfiguration.builder(ConfigMap.class).build(); + assertNull(informerConfig.getLabelSelector()); + } + + @Test + void shouldWatchAllNamespacesByDefaultForControllers() { + final var informerConfig = InformerConfiguration.builder(ConfigMap.class).buildForController(); + assertTrue(informerConfig.watchAllNamespaces()); + } + + @Test + void shouldFollowControllerNamespacesByDefaultForInformerEventSource() { + final var informerConfig = InformerConfiguration.builder(ConfigMap.class).build(); + assertTrue(informerConfig.getFollowControllerNamespaceChanges()); + } + + @Test + void failIfNotValid() { + assertThrows(IllegalArgumentException.class, () -> InformerConfiguration.failIfNotValid(null)); + assertThrows( + IllegalArgumentException.class, + () -> InformerConfiguration.failIfNotValid(Collections.emptySet())); + assertThrows( + IllegalArgumentException.class, + () -> + InformerConfiguration.failIfNotValid( + Set.of(Constants.WATCH_CURRENT_NAMESPACE, Constants.WATCH_ALL_NAMESPACES, "foo"))); + assertThrows( + IllegalArgumentException.class, + () -> + InformerConfiguration.failIfNotValid(Set.of(Constants.WATCH_CURRENT_NAMESPACE, "foo"))); + assertThrows( + IllegalArgumentException.class, + () -> InformerConfiguration.failIfNotValid(Set.of(Constants.WATCH_ALL_NAMESPACES, "foo"))); + + // should work + InformerConfiguration.failIfNotValid(Set.of("foo", "bar")); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/MockControllerConfiguration.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/MockControllerConfiguration.java new file mode 100644 index 0000000000..afd01d0f90 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/MockControllerConfiguration.java @@ -0,0 +1,31 @@ +package io.javaoperatorsdk.operator.api.config; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_NAMESPACES_SET; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MockControllerConfiguration { + + public static ControllerConfiguration forResource( + Class resourceType) { + return forResource(resourceType, null); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + public static ControllerConfiguration forResource( + Class resourceType, ConfigurationService configurationService) { + final ControllerConfiguration configuration = mock(ControllerConfiguration.class); + final InformerConfiguration informerConfiguration = mock(InformerConfiguration.class); + when(configuration.getInformerConfig()).thenReturn(informerConfiguration); + when(configuration.getResourceClass()).thenReturn(resourceType); + when(informerConfiguration.getNamespaces()).thenReturn(DEFAULT_NAMESPACES_SET); + when(informerConfiguration.getEffectiveNamespaces(any())).thenCallRealMethod(); + when(configuration.getName()).thenReturn(resourceType.getSimpleName()); + when(configuration.getConfigurationService()).thenReturn(configurationService); + return configuration; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/UtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/UtilsTest.java new file mode 100644 index 0000000000..a2246f018a --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/UtilsTest.java @@ -0,0 +1,122 @@ +package io.javaoperatorsdk.operator.api.config; + +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.apps.Deployment; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.EmptyTestDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class UtilsTest { + + @Test + void shouldNotCheckCRDAndValidateLocalModelByDefault() { + assertFalse(Utils.shouldCheckCRDAndValidateLocalModel()); + } + + @Test + void shouldNotDebugThreadPoolByDefault() { + assertFalse(Utils.debugThreadPool()); + } + + @Test + void askingForNonexistentPropertyShouldReturnDefault() { + final var key = "foo"; + assertNull(System.getProperty(key)); + assertFalse(Utils.getBooleanFromSystemPropsOrDefault(key, false)); + assertTrue(Utils.getBooleanFromSystemPropsOrDefault(key, true)); + } + + @Test + void askingForExistingPropertyShouldReturnItIfBoolean() { + final var key = "foo"; + try { + System.setProperty(key, "true"); + assertNotNull(System.getProperty(key)); + assertTrue(Utils.getBooleanFromSystemPropsOrDefault(key, false)); + assertTrue(Utils.getBooleanFromSystemPropsOrDefault(key, true)); + + System.setProperty(key, "TruE"); + assertTrue(Utils.getBooleanFromSystemPropsOrDefault(key, false)); + assertTrue(Utils.getBooleanFromSystemPropsOrDefault(key, true)); + + System.setProperty(key, " \tTRUE "); + assertTrue(Utils.getBooleanFromSystemPropsOrDefault(key, false)); + assertTrue(Utils.getBooleanFromSystemPropsOrDefault(key, true)); + + System.setProperty(key, " \nFalSe \t "); + assertFalse(Utils.getBooleanFromSystemPropsOrDefault(key, false)); + assertFalse(Utils.getBooleanFromSystemPropsOrDefault(key, true)); + } finally { + System.clearProperty(key); + } + } + + @Test + void askingForExistingNonBooleanPropertyShouldReturnDefaultValue() { + final var key = "foo"; + try { + System.setProperty(key, "bar"); + assertNotNull(System.getProperty(key)); + assertFalse(Utils.getBooleanFromSystemPropsOrDefault(key, false)); + assertTrue(Utils.getBooleanFromSystemPropsOrDefault(key, true)); + } finally { + System.clearProperty(key); + } + } + + @Test + void getsFirstTypeArgumentFromExtendedClass() { + Class res = + Utils.getFirstTypeArgumentFromExtendedClass(TestKubernetesDependentResource.class); + assertThat(res).isEqualTo(Deployment.class); + } + + @Test + void getsFirstTypeArgumentFromInterface() { + assertThat( + Utils.getFirstTypeArgumentFromInterface( + EmptyTestDependentResource.class, DependentResource.class)) + .isEqualTo(Deployment.class); + + assertThatIllegalArgumentException() + .isThrownBy( + () -> + Utils.getFirstTypeArgumentFromInterface( + TestKubernetesDependentResource.class, DependentResource.class)); + } + + @Test + void getsFirstTypeArgumentFromInterfaceFromParent() { + assertThat( + Utils.getFirstTypeArgumentFromSuperClassOrInterface( + ConcreteReconciler.class, Reconciler.class)) + .isEqualTo(ConfigMap.class); + } + + public abstract static class AbstractReconciler

implements Reconciler

{} + + public static class ConcreteReconciler extends AbstractReconciler { + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) + throws Exception { + return null; + } + } + + public static class TestKubernetesDependentResource + extends KubernetesDependentResource {} +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/VersionTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/VersionTest.java new file mode 100644 index 0000000000..2c3134b6ad --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/VersionTest.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.api.config; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class VersionTest { + + @Test + void versionShouldReturnTheSameResultFromMavenAndProperties() { + String versionFromProperties = Utils.VERSION.getSdkVersion(); + String versionFromMaven = Version.UNKNOWN.getSdkVersion(); + + assertEquals(versionFromProperties, versionFromMaven); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/dependent/DependentResourceConfigurationResolverTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/dependent/DependentResourceConfigurationResolverTest.java new file mode 100644 index 0000000000..27bd2b9dae --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/dependent/DependentResourceConfigurationResolverTest.java @@ -0,0 +1,227 @@ +package io.javaoperatorsdk.operator.api.config.dependent; + +import java.lang.annotation.Annotation; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Optional; + +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.Service; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +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.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ConfiguredDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentConverter; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; + +import static io.javaoperatorsdk.operator.api.config.dependent.DependentResourceConfigurationResolverTest.CustomAnnotationReconciler.DR_NAME; +import static org.junit.jupiter.api.Assertions.*; + +class DependentResourceConfigurationResolverTest { + + // subclass to expose configFor method to this test class + private static final class TestConfigurationService extends BaseConfigurationService { + + @Override + protected

+ io.javaoperatorsdk.operator.api.config.ControllerConfiguration

configFor( + Reconciler

reconciler) { + return super.configFor(reconciler); + } + } + + private final TestConfigurationService configurationService = new TestConfigurationService(); + + private

+ io.javaoperatorsdk.operator.api.config.ControllerConfiguration

configFor( + Reconciler

reconciler) { + // ensure that a new configuration is created each time + return configurationService.configFor(reconciler); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Object extractDependentKubernetesResourceConfig( + io.javaoperatorsdk.operator.api.config.ControllerConfiguration configuration, + Class target) { + final var spec = + configuration.getWorkflowSpec().orElseThrow().getDependentResourceSpecs().stream() + .filter(s -> target.isAssignableFrom(s.getDependentResourceClass())) + .findFirst() + .orElseThrow(); + return configuration.getConfigurationFor(spec); + } + + @Test + void controllerConfigurationProvidedShouldBeReturnedIfAvailable() { + final var cfg = configFor(new CustomAnnotationReconciler()); + + final var customConfig = + extractDependentKubernetesResourceConfig(cfg, CustomAnnotatedDep.class); + assertInstanceOf(CustomConfig.class, customConfig); + assertEquals(CustomAnnotatedDep.PROVIDED_VALUE, ((CustomConfig) customConfig).getValue()); + final var newConfig = new CustomConfig(72); + final var overridden = + ControllerConfigurationOverrider.override(cfg) + .replacingNamedDependentResourceConfig(DR_NAME, newConfig) + .build(); + final var spec = + cfg.getWorkflowSpec().orElseThrow().getDependentResourceSpecs().stream() + .filter(s -> DR_NAME.equals(s.getName())) + .findFirst() + .orElseThrow(); + assertEquals(newConfig, overridden.getConfigurationFor(spec)); + } + + @Test + void getConverterShouldWork() { + // extracting configuration should trigger converter creation + configFor(new CustomAnnotationReconciler()); + var converter = DependentResourceConfigurationResolver.getConverter(CustomAnnotatedDep.class); + assertNotNull(converter); + assertEquals(CustomConfigConverter.class, converter.getClass()); + + converter = DependentResourceConfigurationResolver.getConverter(ChildCustomAnnotatedDep.class); + assertNotNull(converter); + assertEquals(CustomConfigConverter.class, converter.getClass()); + assertEquals( + DependentResourceConfigurationResolver.getConverter(CustomAnnotatedDep.class), converter); + } + + @SuppressWarnings("rawtypes") + @Test + void registerConverterShouldWork() { + final var overriddenConverter = + new ConfigurationConverter() { + + @Override + public Object configFrom( + Annotation configAnnotation, + DependentResourceSpec spec, + io.javaoperatorsdk.operator.api.config.ControllerConfiguration parentConfiguration) { + return null; + } + }; + DependentResourceConfigurationResolver.registerConverter(ServiceDep.class, overriddenConverter); + configFor(new CustomAnnotationReconciler()); + + // non overridden dependents should use the default converter + var converter = DependentResourceConfigurationResolver.getConverter(ConfigMapDep.class); + assertInstanceOf(KubernetesDependentConverter.class, converter); + + // dependent with registered converter should use that one + converter = DependentResourceConfigurationResolver.getConverter(ServiceDep.class); + assertEquals(overriddenConverter, converter); + } + + @Workflow( + dependents = { + @Dependent(type = CustomAnnotatedDep.class, name = DR_NAME), + @Dependent(type = ChildCustomAnnotatedDep.class), + @Dependent(type = ConfigMapDep.class), + @Dependent(type = ServiceDep.class) + }) + @ControllerConfiguration + static class CustomAnnotationReconciler implements Reconciler { + + public static final String DR_NAME = "first"; + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) + throws Exception { + return null; + } + } + + public static class ConfigMapDep extends KubernetesDependentResource + implements GarbageCollected {} + + public static class ServiceDep extends KubernetesDependentResource + implements GarbageCollected {} + + @CustomAnnotation(value = CustomAnnotatedDep.PROVIDED_VALUE) + @Configured( + by = CustomAnnotation.class, + with = CustomConfig.class, + converter = CustomConfigConverter.class) + private static class CustomAnnotatedDep + implements DependentResource, + ConfiguredDependentResource, + GarbageCollected { + + public static final int PROVIDED_VALUE = 42; + private CustomConfig config; + + @Override + public ReconcileResult reconcile(ConfigMap primary, Context context) { + return null; + } + + @Override + public Class resourceType() { + return ConfigMap.class; + } + + @Override + public void configureWith(CustomConfig config) { + this.config = config; + } + + @Override + public Optional configuration() { + return Optional.ofNullable(config); + } + + @Override + public void delete(ConfigMap primary, Context context) {} + } + + private static class ChildCustomAnnotatedDep extends CustomAnnotatedDep {} + + @Retention(RetentionPolicy.RUNTIME) + private @interface CustomAnnotation { + + int value(); + } + + private static class CustomConfig { + + private final int value; + + private CustomConfig(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + } + + private static class CustomConfigConverter + implements ConfigurationConverter { + + static final int CONVERTER_PROVIDED_DEFAULT = 7; + + @Override + public CustomConfig configFrom( + CustomAnnotation configAnnotation, + DependentResourceSpec spec, + io.javaoperatorsdk.operator.api.config.ControllerConfiguration parentConfiguration) { + if (configAnnotation == null) { + return new CustomConfig(CONVERTER_PROVIDED_DEFAULT); + } else { + return new CustomConfig(configAnnotation.value()); + } + } + } +} 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 new file mode 100644 index 0000000000..b289d68b22 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java @@ -0,0 +1,43 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +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 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; + +class DefaultContextTest { + + private final Secret primary = new Secret(); + private final Controller mockController = mock(); + + private final DefaultContext context = new DefaultContext<>(null, mockController, primary); + + @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)); + + var res = context.getSecondaryResource(ConfigMap.class); + assertThat(res).isEmpty(); + } + + @Test + void setRetryInfo() { + RetryInfo retryInfo = mock(); + var newContext = context.setRetryInfo(retryInfo); + assertThat(newContext).isSameAs(context); + assertThat(newContext.getRetryInfo()).hasValue(retryInfo); + } +} 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 new file mode 100644 index 0000000000..80a254b50f --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/PrimaryUpdateAndCacheUtilsTest.java @@ -0,0 +1,164 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.util.Optional; +import java.util.function.UnaryOperator; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.api.config.Cloner; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils.DEFAULT_MAX_RETRY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PrimaryUpdateAndCacheUtilsTest { + + Context context = mock(Context.class); + KubernetesClient client = mock(KubernetesClient.class); + Resource resource = mock(Resource.class); + IndexedResourceCache primaryCache = mock(IndexedResourceCache.class); + + @BeforeEach + void setup() { + when(context.getClient()).thenReturn(client); + var esr = mock(EventSourceRetriever.class); + when(context.eventSourceRetriever()).thenReturn(esr); + when(esr.getControllerEventSource()).thenReturn(mock(ControllerEventSource.class)); + var mixedOp = mock(MixedOperation.class); + when(client.resources(any())).thenReturn(mixedOp); + when(mixedOp.inNamespace(any())).thenReturn(mixedOp); + when(mixedOp.withName(any())).thenReturn(resource); + when(resource.get()).thenReturn(TestUtils.testCustomResource1()); + when(context.getPrimaryCache()).thenReturn(primaryCache); + + var controllerConfiguration = mock(ControllerConfiguration.class); + when(context.getControllerConfiguration()).thenReturn(controllerConfiguration); + var configService = mock(ConfigurationService.class); + when(controllerConfiguration.getConfigurationService()).thenReturn(configService); + when(configService.getResourceCloner()) + .thenReturn( + new Cloner() { + @Override + public R clone(R object) { + return new KubernetesSerialization().clone(object); + } + }); + } + + @Test + void handlesUpdate() { + var updated = + PrimaryUpdateAndCacheUtils.updateAndCacheResource( + TestUtils.testCustomResource1(), + context, + r -> { + var res = TestUtils.testCustomResource1(); + // setting this to null to test if value set in the implementation + res.getMetadata().setResourceVersion(null); + res.getSpec().setValue("updatedValue"); + return res; + }, + r -> { + // checks if the resource version is set from the original resource + assertThat(r.getMetadata().getResourceVersion()).isEqualTo("1"); + var res = TestUtils.testCustomResource1(); + res.setSpec(r.getSpec()); + res.getMetadata().setResourceVersion("2"); + return res; + }); + + assertThat(updated.getMetadata().getResourceVersion()).isEqualTo("2"); + assertThat(updated.getSpec().getValue()).isEqualTo("updatedValue"); + } + + @Test + void retriesConflicts() { + var updateOperation = mock(UnaryOperator.class); + + when(updateOperation.apply(any())) + .thenThrow(new KubernetesClientException("", 409, null)) + .thenReturn(TestUtils.testCustomResource1()); + var freshResource = TestUtils.testCustomResource1(); + + freshResource.getMetadata().setResourceVersion("2"); + when(primaryCache.get(any())).thenReturn(Optional.of(freshResource)); + + var updated = + PrimaryUpdateAndCacheUtils.updateAndCacheResource( + TestUtils.testCustomResource1(), + context, + r -> { + var res = TestUtils.testCustomResource1(); + res.getSpec().setValue("updatedValue"); + return res; + }, + updateOperation); + + assertThat(updated).isNotNull(); + verify(primaryCache, times(1)).get(any()); + } + + @Test + void throwsIfRetryExhausted() { + var updateOperation = mock(UnaryOperator.class); + + when(updateOperation.apply(any())).thenThrow(new KubernetesClientException("", 409, null)); + var stubbing = when(primaryCache.get(any())); + + for (int i = 0; i < DEFAULT_MAX_RETRY; i++) { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("" + i); + stubbing = stubbing.thenReturn(Optional.of(resource)); + } + assertThrows( + OperatorException.class, + () -> + PrimaryUpdateAndCacheUtils.updateAndCacheResource( + TestUtils.testCustomResource1(), + context, + UnaryOperator.identity(), + updateOperation)); + verify(primaryCache, times(DEFAULT_MAX_RETRY)).get(any()); + } + + @Test + void cachePollTimeouts() { + var updateOperation = mock(UnaryOperator.class); + + when(updateOperation.apply(any())).thenThrow(new KubernetesClientException("", 409, null)); + when(primaryCache.get(any())).thenReturn(Optional.of(TestUtils.testCustomResource1())); + + var ex = + assertThrows( + OperatorException.class, + () -> + PrimaryUpdateAndCacheUtils.updateAndCacheResource( + TestUtils.testCustomResource1(), + context, + UnaryOperator.identity(), + updateOperation, + 2, + 50L, + 10L)); + assertThat(ex.getMessage()).contains("Timeout"); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/DefaultManagedDependentResourceContextTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/DefaultManagedDependentResourceContextTest.java new file mode 100644 index 0000000000..3a1e44de96 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/dependent/managed/DefaultManagedDependentResourceContextTest.java @@ -0,0 +1,95 @@ +package io.javaoperatorsdk.operator.api.reconciler.dependent.managed; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DefaultManagedDependentResourceContextTest { + + private final ManagedWorkflowAndDependentResourceContext context = + new DefaultManagedWorkflowAndDependentResourceContext<>(null, null, null); + + @Test + void getWhenEmpty() { + Optional actual = context.get("key", String.class); + assertThat(actual).isEmpty(); + } + + @Test + void get() { + context.put("key", "value"); + Optional actual = context.get("key", String.class); + assertThat(actual).contains("value"); + } + + @Test + void putNewValueOverwrites() { + context.put("key", "value"); + context.put("key", "valueB"); + Optional actual = context.get("key", String.class); + assertThat(actual).contains("valueB"); + } + + @Test + void putNewValueReturnsPriorValue() { + final var prior = "value"; + context.put("key", prior); + String actual = context.put("key", "valueB"); + assertThat(actual).isEqualTo(prior); + } + + @Test + void putNewValueLogsWarningIfTypesDiffer() { + // to check that we properly log things without setting up a complex fixture + final String[] messages = new String[1]; + var context = + new DefaultManagedWorkflowAndDependentResourceContext<>(null, null, null) { + @Override + void logWarning(String message) { + messages[0] = message; + } + }; + final var prior = "value"; + final var key = "key"; + context.put(key, prior); + context.put(key, 10); + assertThat(messages[0]).contains(key).contains(prior).contains("put(" + key + ", null)"); + } + + @Test + void putNullRemoves() { + context.put("key", "value"); + context.put("key", null); + Optional actual = context.get("key", String.class); + assertThat(actual).isEmpty(); + } + + @Test + void putNullReturnsPriorValue() { + context.put("key", "value"); + String actual = context.put("key", null); + assertThat(actual).contains("value"); + } + + @Test + void getMandatory() { + context.put("key", "value"); + String actual = context.getMandatory("key", String.class); + assertThat(actual).isEqualTo("value"); + } + + @Test + void getMandatoryWhenEmpty() { + assertThatThrownBy( + () -> { + context.getMandatory("key", String.class); + }) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "Mandatory attribute (key: key, type: java.lang.String) is missing or not of the" + + " expected type"); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/ControllerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/ControllerTest.java new file mode 100644 index 0000000000..82ecdb111a --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/ControllerTest.java @@ -0,0 +1,136 @@ +package io.javaoperatorsdk.operator.processing; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.MockControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec; +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.processing.dependent.workflow.ManagedWorkflow; +import io.javaoperatorsdk.operator.processing.dependent.workflow.ManagedWorkflowFactory; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Workflow; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowCleanupResult; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static io.javaoperatorsdk.operator.api.monitoring.Metrics.NOOP; +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.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +@SuppressWarnings({"unchecked", "rawtypes"}) +class ControllerTest { + + final Reconciler reconciler = mock(Reconciler.class); + + ConfigurationService configurationService = new BaseConfigurationService(); + + @Test + void crdShouldNotBeCheckedForNativeResources() { + final var client = MockKubernetesClient.client(Secret.class); + final var configuration = + MockControllerConfiguration.forResource(Secret.class, configurationService); + final var controller = new Controller(reconciler, configuration, client); + controller.start(); + verify(client, never()).apiextensions(); + } + + @Test + void crdShouldNotBeCheckedForCustomResourcesIfDisabled() { + final var client = MockKubernetesClient.client(TestCustomResource.class); + ConfigurationService configurationService = + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), o -> o.checkingCRDAndValidateLocalModel(false)); + + final var configuration = + MockControllerConfiguration.forResource(TestCustomResource.class, configurationService); + final var controller = new Controller(reconciler, configuration, client); + controller.start(); + verify(client, never()).apiextensions(); + } + + @Test + void usesFinalizerIfThereIfReconcilerImplementsCleaner() { + Reconciler reconciler = mock(Reconciler.class, withSettings().extraInterfaces(Cleaner.class)); + final var configuration = MockControllerConfiguration.forResource(Secret.class); + when(configuration.getConfigurationService()).thenReturn(new BaseConfigurationService()); + + final var controller = + new Controller( + reconciler, configuration, MockKubernetesClient.client(Secret.class)); + + assertThat(controller.useFinalizer()).isTrue(); + } + + @ParameterizedTest + @CsvSource({ + "true, true, true, false", + "true, true, false, true", + "false, true, true, true", + "false, true, false, true", + "true, false, true, false", + }) + void callsCleanupOnWorkflowWhenHasCleanerAndReconcilerIsNotCleaner( + boolean reconcilerIsCleaner, + boolean workflowIsCleaner, + boolean isExplicitWorkflowInvocation, + boolean workflowCleanerExecuted) + throws Exception { + + Reconciler reconciler; + if (reconcilerIsCleaner) { + reconciler = mock(Reconciler.class, withSettings().extraInterfaces(Cleaner.class)); + } else { + reconciler = mock(Reconciler.class); + } + + final var configuration = MockControllerConfiguration.forResource(Secret.class); + + if (reconciler instanceof Cleaner cleaner) { + when(cleaner.cleanup(any(), any())).thenReturn(DeleteControl.noFinalizerRemoval()); + } + + var configurationService = mock(ConfigurationService.class); + var mockWorkflowFactory = mock(ManagedWorkflowFactory.class); + var mockManagedWorkflow = mock(ManagedWorkflow.class); + + when(configuration.getConfigurationService()).thenReturn(configurationService); + var workflowSpec = mock(WorkflowSpec.class); + when(workflowSpec.isExplicitInvocation()).thenReturn(isExplicitWorkflowInvocation); + when(configuration.getWorkflowSpec()).thenReturn(Optional.of(workflowSpec)); + when(configurationService.getMetrics()).thenReturn(NOOP); + when(configurationService.getWorkflowFactory()).thenReturn(mockWorkflowFactory); + when(mockWorkflowFactory.workflowFor(any())).thenReturn(mockManagedWorkflow); + var managedWorkflowMock = workflow(workflowIsCleaner); + when(mockManagedWorkflow.resolve(any(), any())).thenReturn(managedWorkflowMock); + + final var controller = + new Controller( + reconciler, configuration, MockKubernetesClient.client(Secret.class)); + + controller.cleanup(new Secret(), new DefaultContext<>(null, controller, new Secret())); + + verify(managedWorkflowMock, times(workflowCleanerExecuted ? 1 : 0)).cleanup(any(), any()); + } + + private Workflow workflow(boolean hasCleaner) { + var workflow = mock(Workflow.class); + when(workflow.cleanup(any(), any())).thenReturn(mock(WorkflowCleanupResult.class)); + when(workflow.hasCleaner()).thenReturn(hasCleaner); + return workflow; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/GroupVersionKindTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/GroupVersionKindTest.java new file mode 100644 index 0000000000..d871668c4d --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/GroupVersionKindTest.java @@ -0,0 +1,102 @@ +package io.javaoperatorsdk.operator.processing; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GroupVersionKindPlural; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class GroupVersionKindTest { + + @Test + void testInitFromApiVersion() { + var gvk = new GroupVersionKind("v1", "ConfigMap"); + assertThat(gvk.getGroup()).isNull(); + assertThat(gvk.getVersion()).isEqualTo("v1"); + + gvk = new GroupVersionKind("apps/v1", "Deployment"); + assertThat(gvk.getGroup()).isEqualTo("apps"); + assertThat(gvk.getVersion()).isEqualTo("v1"); + } + + @Test + void parseGVK() { + var gvk = GroupVersionKind.fromString("apps/v1/Deployment"); + assertThat(gvk.getGroup()).isEqualTo("apps"); + assertThat(gvk.getVersion()).isEqualTo("v1"); + assertThat(gvk.getKind()).isEqualTo("Deployment"); + + gvk = GroupVersionKind.fromString("v1/ConfigMap"); + assertThat(gvk.getGroup()).isNull(); + assertThat(gvk.getVersion()).isEqualTo("v1"); + assertThat(gvk.getKind()).isEqualTo("ConfigMap"); + + assertThrows(IllegalArgumentException.class, () -> GroupVersionKind.fromString("v1#ConfigMap")); + assertThrows( + IllegalArgumentException.class, () -> GroupVersionKind.fromString("api/beta/v1/ConfigMap")); + } + + @Test + void pluralShouldOnlyBeProvidedIfExplicitlySet() { + final var kind = "ConfigMap"; + var gvk = GroupVersionKindPlural.from(new GroupVersionKind("v1", kind)); + assertThat(gvk.getPlural()).isEmpty(); + assertThat(gvk.getPluralOrDefault()) + .isEqualTo(GroupVersionKindPlural.getDefaultPluralFor(kind)); + + gvk = GroupVersionKindPlural.from(GroupVersionKind.gvkFor(ConfigMap.class)); + assertThat(gvk.getPlural()).isEmpty(); + assertThat(gvk.getPluralOrDefault()).isEqualTo(HasMetadata.getPlural(ConfigMap.class)); + + gvk = GroupVersionKindPlural.gvkFor(ConfigMap.class); + assertThat(gvk.getPlural()).hasValue(HasMetadata.getPlural(ConfigMap.class)); + + gvk = GroupVersionKindPlural.from(gvk); + assertThat(gvk.getPlural()).hasValue(HasMetadata.getPlural(ConfigMap.class)); + } + + @Test + void pluralShouldBeEmptyIfNotProvided() { + final var kind = "MyKind"; + final var original = new GroupVersionKind("josdk.io", "v1", kind); + var gvk = GroupVersionKindPlural.gvkWithPlural(original, null); + assertThat(gvk.getPlural()).isEmpty(); + assertThat(gvk.getPluralOrDefault()) + .isEqualTo(GroupVersionKindPlural.getDefaultPluralFor(kind)); + assertThat(gvk).isEqualTo(original); + assertThat(original).isEqualTo(gvk); + assertThat(gvk.hashCode()).isEqualTo(original.hashCode()); + } + + @Test + void pluralShouldOverrideDefaultComputedVersionIfProvided() { + final var original = new GroupVersionKind("josdk.io", "v1", "MyKind"); + final var gvk = GroupVersionKindPlural.gvkWithPlural(original, "MyPlural"); + assertThat(gvk.getPlural()).hasValue("MyPlural"); + assertThat(gvk).isNotEqualTo(original); + assertThat(original).isNotEqualTo(gvk); + assertThat(gvk.hashCode()).isNotEqualTo(original.hashCode()); + } + + @Test + void equals() { + final var original = new GroupVersionKind("josdk.io", "v1", "MyKind"); + assertEquals(original, original); + assertFalse(original.equals(null)); + } + + @Test + void encodesToGVKString() { + final var deploymentGVK = "apps/v1/Deployment"; + var gvk = GroupVersionKind.fromString(deploymentGVK); + assertThat(gvk.toGVKString()).isEqualTo(deploymentGVK); + assertThat(gvk).isEqualTo(GroupVersionKind.fromString(gvk.toGVKString())); + + gvk = GroupVersionKind.fromString("v1/ConfigMap"); + assertThat(gvk.toGVKString()).isEqualTo("v1/ConfigMap"); + assertThat(gvk).isEqualTo(GroupVersionKind.fromString(gvk.toGVKString())); + } +} 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 new file mode 100644 index 0000000000..939d046e5d --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java @@ -0,0 +1,145 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AbstractDependentResourceTest { + + @Test + void throwsExceptionIfDesiredIsNullOnCreate() { + TestDependentResource testDependentResource = new TestDependentResource(); + testDependentResource.setSecondary(null); + testDependentResource.setDesired(null); + + assertThrows( + DependentResourceException.class, + () -> testDependentResource.reconcile(new TestCustomResource(), null)); + } + + @Test + void throwsExceptionIfDesiredIsNullOnUpdate() { + TestDependentResource testDependentResource = new TestDependentResource(); + testDependentResource.setSecondary(configMap()); + testDependentResource.setDesired(null); + + assertThrows( + DependentResourceException.class, + () -> testDependentResource.reconcile(new TestCustomResource(), null)); + } + + @Test + void throwsExceptionIfCreateReturnsNull() { + TestDependentResource testDependentResource = new TestDependentResource(); + testDependentResource.setSecondary(null); + testDependentResource.setDesired(configMap()); + + assertThrows( + DependentResourceException.class, + () -> testDependentResource.reconcile(new TestCustomResource(), null)); + } + + @Test + void throwsExceptionIfUpdateReturnsNull() { + TestDependentResource testDependentResource = new TestDependentResource(); + testDependentResource.setSecondary(configMap()); + testDependentResource.setDesired(configMap()); + + assertThrows( + DependentResourceException.class, + () -> testDependentResource.reconcile(new TestCustomResource(), null)); + } + + private ConfigMap configMap() { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder().withName("test").withNamespace("default").build()); + return configMap; + } + + private static class TestDependentResource + extends AbstractDependentResource + implements Creator, Updater { + + private ConfigMap secondary; + private ConfigMap desired; + + @Override + public Class resourceType() { + return ConfigMap.class; + } + + @Override + public Optional getSecondaryResource( + TestCustomResource primary, Context context) { + return Optional.ofNullable(secondary); + } + + @Override + protected void onCreated( + TestCustomResource primary, ConfigMap created, Context context) {} + + @Override + protected void onUpdated( + TestCustomResource primary, + ConfigMap updated, + ConfigMap actual, + Context context) {} + + @Override + protected ConfigMap desired(TestCustomResource primary, Context context) { + return desired; + } + + public ConfigMap getSecondary() { + return secondary; + } + + public TestDependentResource setSecondary(ConfigMap secondary) { + this.secondary = secondary; + return this; + } + + public ConfigMap getDesired() { + return desired; + } + + public TestDependentResource setDesired(ConfigMap desired) { + this.desired = desired; + return this; + } + + @Override + public ConfigMap create( + ConfigMap desired, TestCustomResource primary, Context context) { + return null; + } + + @Override + public ConfigMap update( + ConfigMap actual, + ConfigMap desired, + TestCustomResource primary, + Context context) { + return null; + } + + @Override + @SuppressWarnings("unchecked") + public Matcher.Result match( + ConfigMap actualResource, TestCustomResource primary, Context context) { + var result = mock(Matcher.Result.class); + when(result.matched()).thenReturn(false); + return result; + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/EmptyTestDependentResource.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/EmptyTestDependentResource.java new file mode 100644 index 0000000000..9d88f90a0f --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/EmptyTestDependentResource.java @@ -0,0 +1,33 @@ +package io.javaoperatorsdk.operator.processing.dependent; + +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +public class EmptyTestDependentResource + implements DependentResource { + + private String name; + + @Override + public ReconcileResult reconcile( + TestCustomResource primary, Context context) { + return null; + } + + @Override + public Class resourceType() { + return Deployment.class; + } + + @Override + public String name() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} 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 new file mode 100644 index 0000000000..3062e360e2 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java @@ -0,0 +1,225 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +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.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +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); + + 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(); + assertThat(GenericKubernetesResourceMatcher.match(desired, actual, context).computedDesired()) + .isPresent(); + assertThat(GenericKubernetesResourceMatcher.match(desired, actual, context).computedDesired()) + .contains(desired); + } + + @Test + void matchesAdditiveOnlyChanges() { + actual.getSpec().getTemplate().getMetadata().getLabels().put("new-key", "val"); + assertThat(GenericKubernetesResourceMatcher.match(desired, actual, context).matched()) + .withFailMessage("Additive changes should not cause a mismatch by default") + .isTrue(); + } + + @Test + void matchesWithStrongSpecEquality() { + actual.getSpec().getTemplate().getMetadata().getLabels().put("new-key", "val"); + assertThat(match(desired, actual, true, true, context).matched()) + .withFailMessage("Adding values should fail matching when strong equality is required") + .isFalse(); + } + + @Test + void doesNotMatchRemovedValues() { + actual = createDeployment(); + assertThat( + GenericKubernetesResourceMatcher.match( + dependentResource.desired(createPrimary("removed"), null), actual, context) + .matched()) + .withFailMessage("Removing values in metadata should lead to a mismatch") + .isFalse(); + } + + @Test + void doesNotMatchChangedValues() { + actual = createDeployment(); + actual.getSpec().setReplicas(2); + assertThat(GenericKubernetesResourceMatcher.match(desired, actual, context).matched()) + .withFailMessage("Should not have matched because values have changed") + .isFalse(); + } + + @Test + void ignoreStatus() { + actual = createDeployment(); + actual.setStatus(new DeploymentStatusBuilder().withReadyReplicas(1).build()); + assertThat(GenericKubernetesResourceMatcher.match(desired, actual, context).matched()) + .withFailMessage("Should ignore status in actual") + .isTrue(); + } + + @Test + void doesNotMatchChangedValuesWhenNoIgnoredPathsAreProvided() { + actual = createDeployment(); + actual.getSpec().setReplicas(2); + assertThat(match(dependentResource, actual, null, context, true).matched()) + .withFailMessage( + "Should not have matched because values have changed and no ignored path is provided") + .isFalse(); + } + + @Test + void doesNotAttemptToMatchIgnoredPaths() { + actual = createDeployment(); + actual.getSpec().setReplicas(2); + assertThat(match(dependentResource, actual, null, context, false, "/spec/replicas").matched()) + .withFailMessage("Should not have compared ignored paths") + .isTrue(); + } + + @Test + void ignoresWholeSubPath() { + actual = createDeployment(); + actual.getSpec().getTemplate().getMetadata().getLabels().put("additional-key", "val"); + assertThat(match(dependentResource, actual, null, context, false, "/spec/template").matched()) + .withFailMessage("Should match when only changes impact ignored sub-paths") + .isTrue(); + } + + @Test + void matchesMetadata() { + actual = + new DeploymentBuilder(createDeployment()) + .editOrNewMetadata() + .addToAnnotations("test", "value") + .endMetadata() + .build(); + assertThat(match(dependentResource, actual, null, context, false).matched()) + .withFailMessage("Annotations shouldn't matter when metadata is not considered") + .isTrue(); + + assertThat(match(desired, actual, true, true, context).matched()) + .withFailMessage("Annotations should matter when metadata is considered") + .isFalse(); + + assertThat(match(desired, actual, false, false, context).matched()) + .withFailMessage( + "Should match when strong equality is not considered and only additive changes are" + + " made") + .isTrue(); + } + + @Test + void checkServiceAccount() { + final var serviceAccountDR = new ServiceAccountDR(); + + final var desired = serviceAccountDR.desired(null, context); + var actual = + new ServiceAccountBuilder(desired).addNewImagePullSecret("imagePullSecret3").build(); + + assertThat( + GenericKubernetesResourceMatcher.match(desired, actual, false, false, context) + .matched()) + .isTrue(); + } + + @Test + void matchConfigMap() { + var desired = createConfigMap(); + var actual = createConfigMap(); + actual.getData().put("key2", "val2"); + + var match = GenericKubernetesResourceMatcher.match(desired, actual, true, false, context); + assertThat(match.matched()).isTrue(); + } + + ConfigMap createConfigMap() { + return new ConfigMapBuilder() + .withMetadata(new ObjectMetaBuilder().withName("tes1").withNamespace("default").build()) + .withData(Map.of("key1", "val1")) + .build(); + } + + Deployment createDeployment() { + return ReconcilerUtils.loadYaml( + Deployment.class, GenericKubernetesResourceMatcherTest.class, "nginx-deployment.yaml"); + } + + HasMetadata createPrimary(String caseName) { + return new DeploymentBuilder() + .editOrNewMetadata() + .addToLabels("case", caseName) + .endMetadata() + .build(); + } + + private static class ServiceAccountDR + extends KubernetesDependentResource { + + @Override + protected ServiceAccount desired(HasMetadata primary, Context context) { + return new ServiceAccountBuilder() + .withNewMetadata() + .withName("foo") + .endMetadata() + .withAutomountServiceAccountToken() + .addNewImagePullSecret("imagePullSecret1") + .addNewImagePullSecret("imagePullSecret2") + .build(); + } + } + + private class TestDependentResource extends KubernetesDependentResource { + + private final Deployment desired; + + public TestDependentResource(Deployment desired) { + super(Deployment.class); + this.desired = desired; + } + + @Override + protected Deployment desired(HasMetadata primary, Context context) { + final var currentCase = + Optional.ofNullable(primary) + .map(p -> p.getMetadata().getLabels().get("case")) + .orElse(null); + var d = desired; + if ("removed".equals(currentCase)) { + d = createDeployment(); + d.getSpec().getTemplate().getMetadata().getLabels().put("new-key", "val"); + } + return d; + } + } +} 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 new file mode 100644 index 0000000000..0acc4ed09e --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericResourceUpdaterTest.java @@ -0,0 +1,122 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SuppressWarnings("rawtypes") +class GenericResourceUpdaterTest { + + private static final Context context = mock(Context.class); + + @BeforeAll + static void setUp() { + final var controllerConfiguration = mock(ControllerConfiguration.class); + final var configService = mock(ConfigurationService.class); + when(controllerConfiguration.getConfigurationService()).thenReturn(configService); + + final var client = MockKubernetesClient.client(HasMetadata.class); + when(configService.getKubernetesClient()).thenReturn(client); + when(configService.getResourceCloner()).thenCallRealMethod(); + when(context.getClient()).thenReturn(client); + when(context.getControllerConfiguration()).thenReturn(controllerConfiguration); + } + + @Test + void preservesValues() { + var desired = createDeployment(); + var actual = createDeployment(); + actual.getMetadata().setLabels(new HashMap<>()); + actual.getMetadata().getLabels().put("additionalActualKey", "value"); + actual.getMetadata().setResourceVersion("1234"); + actual.getSpec().setRevisionHistoryLimit(5); + + var result = GenericResourceUpdater.updateResource(actual, desired, context); + + assertThat(result.getMetadata().getLabels().get("additionalActualKey")).isEqualTo("value"); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("1234"); + assertThat(result.getSpec().getRevisionHistoryLimit()).isEqualTo(10); + } + + @Test + void checkNamespaces() { + var desired = new NamespaceBuilder().withNewMetadata().withName("foo").endMetadata().build(); + var actual = new NamespaceBuilder().withNewMetadata().withName("foo").endMetadata().build(); + actual.getMetadata().setLabels(new HashMap<>()); + actual.getMetadata().getLabels().put("additionalActualKey", "value"); + actual.getMetadata().setResourceVersion("1234"); + + var result = GenericResourceUpdater.updateResource(actual, desired, context); + assertThat(result.getMetadata().getLabels().get("additionalActualKey")).isEqualTo("value"); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("1234"); + + desired.setSpec(new NamespaceSpec(List.of("halkyon.io/finalizer"))); + + result = GenericResourceUpdater.updateResource(actual, desired, context); + assertThat(result.getMetadata().getLabels().get("additionalActualKey")).isEqualTo("value"); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("1234"); + assertThat(result.getSpec().getFinalizers()).containsExactly("halkyon.io/finalizer"); + + desired = new NamespaceBuilder().withNewMetadata().withName("foo").endMetadata().build(); + + result = GenericResourceUpdater.updateResource(actual, desired, context); + assertThat(result.getMetadata().getLabels().get("additionalActualKey")).isEqualTo("value"); + assertThat(result.getMetadata().getResourceVersion()).isEqualTo("1234"); + assertThat(result.getSpec()).isNull(); + } + + @Test + void checkSecret() { + var desired = + new SecretBuilder() + .withMetadata(new ObjectMeta()) + .withImmutable() + .withType("Opaque") + .addToData("foo", "bar") + .build(); + var actual = new SecretBuilder().withMetadata(new ObjectMeta()).build(); + + final var secret = GenericResourceUpdater.updateResource(actual, desired, context); + assertThat(secret.getImmutable()).isTrue(); + assertThat(secret.getType()).isEqualTo("Opaque"); + assertThat(secret.getData()).containsOnlyKeys("foo"); + } + + @Test + void checkServiceAccount() { + var desired = + new ServiceAccountBuilder() + .withMetadata(new ObjectMetaBuilder().addToLabels("new", "label").build()) + .build(); + var actual = + new ServiceAccountBuilder() + .withMetadata(new ObjectMetaBuilder().addToLabels("a", "label").build()) + .withImagePullSecrets(new LocalObjectReferenceBuilder().withName("secret").build()) + .build(); + + final var serviceAccount = GenericResourceUpdater.updateResource(actual, desired, context); + assertThat(serviceAccount.getMetadata().getLabels()) + .isEqualTo(Map.of("a", "label", "new", "label")); + assertThat(serviceAccount.getImagePullSecrets()).isNullOrEmpty(); + } + + Deployment createDeployment() { + return ReconcilerUtils.loadYaml( + Deployment.class, GenericResourceUpdaterTest.class, "nginx-deployment.yaml"); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceTest.java new file mode 100644 index 0000000000..6c48311f4b --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceTest.java @@ -0,0 +1,76 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static io.javaoperatorsdk.operator.api.config.Utils.GENERIC_PARAMETER_TYPE_ERROR_PREFIX; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class KubernetesDependentResourceTest { + + @ParameterizedTest + @ValueSource( + classes = { + TestDeploymentDependentResource.class, + ChildTestDeploymentDependentResource.class, + GrandChildTestDeploymentDependentResource.class, + ChildTypeWithValidKubernetesDependentResource.class, + ConstructorOverridedCorrectDeployementDependentResource.class + }) + void checkResourceTypeDerivationWithInheritance(Class clazz) throws Exception { + KubernetesDependentResource dependentResource = + (KubernetesDependentResource) clazz.getDeclaredConstructor().newInstance(); + assertThat(dependentResource).isInstanceOf(KubernetesDependentResource.class); + assertThat(dependentResource.resourceType()).isEqualTo(Deployment.class); + } + + private static class TestDeploymentDependentResource + extends KubernetesDependentResource {} + + private static class ChildTestDeploymentDependentResource + extends TestDeploymentDependentResource {} + + private static class GrandChildTestDeploymentDependentResource + extends ChildTestDeploymentDependentResource {} + + private static class ChildTypeWithValidKubernetesDependentResource + extends KubernetesDependentResource {} + + private static class ConstructorOverridedCorrectDeployementDependentResource + extends KubernetesDependentResource { + public ConstructorOverridedCorrectDeployementDependentResource() { + super(Deployment.class); + } + } + + @Test + void validateInvalidTypeDerivationTypesThrowException() { + assertThatThrownBy(() -> new InvalidChildTestDeploymentDependentResource()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + GENERIC_PARAMETER_TYPE_ERROR_PREFIX + + InvalidChildTestDeploymentDependentResource.class.getName() + + " because it doesn't extend a class that is parametrized with the type we want to" + + " retrieve or because it's Object.class. Please provide the resource type in the " + + "constructor (e.g., super(Deployment.class)."); + assertThatThrownBy(() -> new InvalidGrandChildTestDeploymentDependentResource()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage( + GENERIC_PARAMETER_TYPE_ERROR_PREFIX + + InvalidGrandChildTestDeploymentDependentResource.class.getName() + + " because it doesn't extend a class that is parametrized with the type we want to" + + " retrieve or because it's Object.class. Please provide the resource type in the " + + "constructor (e.g., super(Deployment.class)."); + } + + private static class InvalidChildTestDeploymentDependentResource + extends ChildTypeWithValidKubernetesDependentResource {} + + private static class InvalidGrandChildTestDeploymentDependentResource + extends InvalidChildTestDeploymentDependentResource {} +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/PodTemplateSpecSanitizerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/PodTemplateSpecSanitizerTest.java new file mode 100644 index 0000000000..e7756b45bc --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/PodTemplateSpecSanitizerTest.java @@ -0,0 +1,451 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.ListAssert; +import org.assertj.core.api.MapAssert; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.EnvVarBuilder; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder; +import io.fabric8.kubernetes.api.model.Quantity; +import io.fabric8.kubernetes.api.model.apps.StatefulSet; +import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; +import io.javaoperatorsdk.operator.MockKubernetesClient; + +import static io.javaoperatorsdk.operator.processing.dependent.kubernetes.PodTemplateSpecSanitizer.sanitizePodTemplateSpec; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests the {@link PodTemplateSpecSanitizer} with combinations of matching and mismatching K8s + * resources, using a mix of containers and init containers, as well as resource requests and limits + * along with environment variables. + */ +class PodTemplateSpecSanitizerTest { + + private final Map actualMap = mock(); + + private final KubernetesClient client = MockKubernetesClient.client(HasMetadata.class); + private final KubernetesSerialization serialization = client.getKubernetesSerialization(); + + @Test + void testSanitizePodTemplateSpec_whenTemplateIsNull_doNothing() { + final var template = new PodTemplateSpecBuilder().build(); + + sanitizePodTemplateSpec(actualMap, null, template); + sanitizePodTemplateSpec(actualMap, template, null); + verifyNoInteractions(actualMap); + } + + @Test + void testSanitizePodTemplateSpec_whenTemplateSpecIsNull_doNothing() { + final var template = new PodTemplateSpecBuilder().withSpec(null).build(); + final var templateWithSpec = new PodTemplateSpecBuilder().withNewSpec().endSpec().build(); + + sanitizePodTemplateSpec(actualMap, template, templateWithSpec); + sanitizePodTemplateSpec(actualMap, templateWithSpec, template); + verifyNoInteractions(actualMap); + } + + @Test + void testSanitizePodTemplateSpec_whenContainerSizeMismatch_doNothing() { + final var template = + new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withName("test") + .endContainer() + .endSpec() + .build(); + final var templateWithTwoContainers = + new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withName("test") + .endContainer() + .addNewContainer() + .withName("test-new") + .endContainer() + .endSpec() + .build(); + + sanitizePodTemplateSpec(actualMap, template, templateWithTwoContainers); + sanitizePodTemplateSpec(actualMap, templateWithTwoContainers, template); + verifyNoInteractions(actualMap); + } + + @Test + void testSanitizePodTemplateSpec_whenContainerNameMismatch_doNothing() { + final var template = + new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withName("test") + .endContainer() + .endSpec() + .build(); + final var templateWithNewContainerName = + new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withName("test-new") + .endContainer() + .endSpec() + .build(); + + sanitizePodTemplateSpec(actualMap, template, templateWithNewContainerName); + sanitizePodTemplateSpec(actualMap, templateWithNewContainerName, template); + verifyNoInteractions(actualMap); + } + + @Test + void testSanitizePodTemplateSpec_whenResourceIsNull_doNothing() { + final var template = + new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withName("test") + .endContainer() + .endSpec() + .build(); + final var templateWithResource = + new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withName("test") + .withNewResources() + .endResources() + .endContainer() + .endSpec() + .build(); + + sanitizePodTemplateSpec(actualMap, template, templateWithResource); + sanitizePodTemplateSpec(actualMap, templateWithResource, template); + verifyNoInteractions(actualMap); + } + + @Test + void testSanitizeResourceRequirements_whenResourceKeyMismatch_doNothing() { + final var actualMap = + sanitizeRequestsAndLimits( + ContainerType.INIT_CONTAINER, + Map.of("cpu", new Quantity("2")), + Map.of("memory", new Quantity("4Gi")), + Map.of(), + Map.of()); + assertInitContainerResources(actualMap, "requests").hasSize(1).containsEntry("cpu", "2"); + assertInitContainerResources(actualMap, "limits").isNull(); + } + + @Test + void testSanitizePodTemplateSpec_whenResourcesHaveSameAmountAndFormat_doNothing() { + final var actualMap = + sanitizeRequestsAndLimits( + ContainerType.CONTAINER, + Map.of("memory", new Quantity("4Gi")), + Map.of("memory", new Quantity("4Gi")), + Map.of("cpu", new Quantity("2")), + Map.of("cpu", new Quantity("2"))); + assertContainerResources(actualMap, "requests").hasSize(1).containsEntry("memory", "4Gi"); + assertContainerResources(actualMap, "limits").hasSize(1).containsEntry("cpu", "2"); + } + + @Test + void testSanitizePodTemplateSpec_whenResourcesHaveNumericalAmountMismatch_doNothing() { + final var actualMap = + sanitizeRequestsAndLimits( + ContainerType.INIT_CONTAINER, + Map.of("cpu", new Quantity("2"), "memory", new Quantity("4Gi")), + Map.of("cpu", new Quantity("4"), "memory", new Quantity("4Ti")), + Map.of("cpu", new Quantity("2")), + Map.of("cpu", new Quantity("4000m"))); + assertInitContainerResources(actualMap, "requests") + .hasSize(2) + .containsEntry("cpu", "2") + .containsEntry("memory", "4Gi"); + assertInitContainerResources(actualMap, "limits").hasSize(1).containsEntry("cpu", "2"); + } + + @Test + void + testSanitizePodTemplateSpec_whenResourcesHaveNumericalAmountMismatch_withEphemeralStorageAddedByOtherOperator_doNothing() { + // mimics an environment like GKE Autopilot that enforces ephemeral-storage requests and limits + final var actualMap = + sanitizeRequestsAndLimits( + ContainerType.INIT_CONTAINER, + Map.of( + "cpu", + new Quantity("2"), + "memory", + new Quantity("4Gi"), + "ephemeral-storage", + new Quantity("1Gi")), + Map.of("cpu", new Quantity("4"), "memory", new Quantity("4Ti")), + Map.of("cpu", new Quantity("2"), "ephemeral-storage", new Quantity("1Gi")), + Map.of("cpu", new Quantity("4000m"))); + assertInitContainerResources(actualMap, "requests") + .hasSize(3) + .containsEntry("cpu", "2") + .containsEntry("memory", "4Gi") + .containsEntry("ephemeral-storage", "1Gi"); + assertInitContainerResources(actualMap, "limits") + .hasSize(2) + .containsEntry("cpu", "2") + .containsEntry("ephemeral-storage", "1Gi"); + } + + @Test + void + testSanitizeResourceRequirements_whenResourcesHaveAmountAndFormatMismatchWithSameNumericalAmount_thenSanitizeActualMap() { + final var actualMap = + sanitizeRequestsAndLimits( + ContainerType.CONTAINER, + Map.of("cpu", new Quantity("2"), "memory", new Quantity("4Gi")), + Map.of("cpu", new Quantity("2000m"), "memory", new Quantity("4096Mi")), + Map.of("cpu", new Quantity("4")), + Map.of("cpu", new Quantity("4000m"))); + assertContainerResources(actualMap, "requests") + .hasSize(2) + .containsEntry("cpu", "2000m") + .containsEntry("memory", "4096Mi"); + assertContainerResources(actualMap, "limits").hasSize(1).containsEntry("cpu", "4000m"); + } + + @Test + void + testSanitizeResourceRequirements_whenResourcesHaveAmountAndFormatMismatchWithSameNumericalAmount_withEphemeralStorageAddedByOtherOperator_thenSanitizeActualMap() { + // mimics an environment like GKE Autopilot that enforces ephemeral-storage requests and limits + final var actualMap = + sanitizeRequestsAndLimits( + ContainerType.CONTAINER, + Map.of( + "cpu", + new Quantity("2"), + "memory", + new Quantity("4Gi"), + "ephemeral-storage", + new Quantity("1Gi")), + Map.of("cpu", new Quantity("2000m"), "memory", new Quantity("4096Mi")), + Map.of("cpu", new Quantity("4"), "ephemeral-storage", new Quantity("1Gi")), + Map.of("cpu", new Quantity("4000m"))); + assertContainerResources(actualMap, "requests") + .hasSize(3) + .containsEntry("cpu", "2000m") + .containsEntry("memory", "4096Mi") + .containsEntry("ephemeral-storage", "1Gi"); + assertContainerResources(actualMap, "limits") + .hasSize(2) + .containsEntry("cpu", "4000m") + .containsEntry("ephemeral-storage", "1Gi"); + } + + @Test + void testSanitizePodTemplateSpec_whenEnvVarsIsEmpty_doNothing() { + final var template = + new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withName("test") + .endContainer() + .endSpec() + .build(); + final var templateWithEnvVars = + new PodTemplateSpecBuilder() + .withNewSpec() + .addNewContainer() + .withName("test") + .withEnv(List.of(new EnvVarBuilder().withName("FOO").withValue("foobar").build())) + .endContainer() + .endSpec() + .build(); + + sanitizePodTemplateSpec(actualMap, template, templateWithEnvVars); + sanitizePodTemplateSpec(actualMap, templateWithEnvVars, template); + verifyNoInteractions(actualMap); + } + + @Test + void testSanitizePodTemplateSpec_whenActualEnvVarValueIsNotEmpty_doNothing() { + final var actualMap = + sanitizeEnvVars( + ContainerType.CONTAINER, + List.of( + new EnvVarBuilder().withName("FOO").withValue("foo").build(), + new EnvVarBuilder().withName("BAR").withValue("bar").build()), + List.of( + new EnvVarBuilder().withName("FOO").withValue("bar").build(), + new EnvVarBuilder().withName("BAR").withValue("foo").build())); + assertContainerEnvVars(actualMap) + .hasSize(2) + .containsExactly( + Map.of("name", "FOO", "value", "foo"), Map.of("name", "BAR", "value", "bar")); + } + + @Test + void testSanitizePodTemplateSpec_whenActualAndDesiredEnvVarsAreDifferent_doNothing() { + final var actualMap = + sanitizeEnvVars( + ContainerType.INIT_CONTAINER, + List.of(new EnvVarBuilder().withName("FOO").withValue("foo").build()), + List.of(new EnvVarBuilder().withName("BAR").withValue("bar").build())); + assertInitContainerEnvVars(actualMap) + .hasSize(1) + .containsExactly(Map.of("name", "FOO", "value", "foo")); + } + + @Test + void testSanitizePodTemplateSpec_whenActualEnvVarIsEmpty_doNothing() { + final var actualMap = + sanitizeEnvVars( + ContainerType.INIT_CONTAINER, + List.of( + new EnvVarBuilder().withName("FOO").withValue("").build(), + new EnvVarBuilder().withName("BAR").withValue("").build()), + List.of( + new EnvVarBuilder().withName("FOO").withValue("foo").build(), + new EnvVarBuilder().withName("BAR").withValue("").build())); + assertInitContainerEnvVars(actualMap) + .hasSize(2) + .containsExactly(Map.of("name", "FOO", "value", ""), Map.of("name", "BAR", "value", "")); + } + + @Test + void testSanitizePodTemplateSpec_whenActualEnvVarIsNull_doNothing() { + final var actualMap = + sanitizeEnvVars( + ContainerType.CONTAINER, + List.of( + new EnvVarBuilder().withName("FOO").withValue(null).build(), + new EnvVarBuilder().withName("BAR").withValue(null).build()), + List.of( + new EnvVarBuilder().withName("FOO").withValue("foo").build(), + new EnvVarBuilder().withName("BAR").withValue(" ").build())); + assertContainerEnvVars(actualMap) + .hasSize(2) + .containsExactly(Map.of("name", "FOO"), Map.of("name", "BAR")); + } + + @Test + void + testSanitizePodTemplateSpec_whenActualEnvVarIsNull_withDesiredEnvVarEmpty_thenSanitizeActualMap() { + final var actualMap = + sanitizeEnvVars( + ContainerType.CONTAINER, + List.of( + new EnvVarBuilder().withName("FOO").withValue(null).build(), + new EnvVarBuilder().withName("BAR").withValue(null).build()), + List.of( + new EnvVarBuilder().withName("FOO").withValue("").build(), + new EnvVarBuilder().withName("BAR").withValue("").build())); + assertContainerEnvVars(actualMap) + .hasSize(2) + .containsExactly(Map.of("name", "FOO", "value", ""), Map.of("name", "BAR", "value", "")); + } + + private Map sanitizeRequestsAndLimits( + final ContainerType type, + final Map actualRequests, + final Map desiredRequests, + final Map actualLimits, + final Map desiredLimits) { + return sanitize( + type, actualRequests, desiredRequests, actualLimits, desiredLimits, List.of(), List.of()); + } + + private Map sanitizeEnvVars( + final ContainerType type, + final List actualEnvVars, + final List desiredEnvVars) { + return sanitize(type, Map.of(), Map.of(), Map.of(), Map.of(), actualEnvVars, desiredEnvVars); + } + + @SuppressWarnings("unchecked") + private Map sanitize( + final ContainerType type, + final Map actualRequests, + final Map desiredRequests, + final Map actualLimits, + final Map desiredLimits, + final List actualEnvVars, + final List desiredEnvVars) { + final var actual = createStatefulSet(type, actualRequests, actualLimits, actualEnvVars); + final var desired = createStatefulSet(type, desiredRequests, desiredLimits, desiredEnvVars); + final var actualMap = serialization.convertValue(actual, Map.class); + sanitizePodTemplateSpec( + actualMap, actual.getSpec().getTemplate(), desired.getSpec().getTemplate()); + return actualMap; + } + + private enum ContainerType { + CONTAINER, + INIT_CONTAINER, + } + + private static StatefulSet createStatefulSet( + final ContainerType type, + final Map requests, + final Map limits, + final List envVars) { + var builder = new StatefulSetBuilder().withNewSpec().withNewTemplate().withNewSpec(); + if (type == ContainerType.CONTAINER) { + builder = + builder + .addNewContainer() + .withName("test") + .withNewResources() + .withRequests(requests) + .withLimits(limits) + .endResources() + .withEnv(envVars) + .endContainer(); + } else { + builder = + builder + .addNewInitContainer() + .withName("test") + .withNewResources() + .withRequests(requests) + .withLimits(limits) + .endResources() + .withEnv(envVars) + .endInitContainer(); + } + return builder.endSpec().endTemplate().endSpec().build(); + } + + private static MapAssert assertContainerResources( + final Map actualMap, final String resourceName) { + return assertThat( + GenericKubernetesResource.>get( + actualMap, "spec", "template", "spec", "containers", 0, "resources", resourceName)); + } + + private static MapAssert assertInitContainerResources( + final Map actualMap, final String resourceName) { + return assertThat( + GenericKubernetesResource.>get( + actualMap, "spec", "template", "spec", "initContainers", 0, "resources", resourceName)); + } + + private static ListAssert> assertContainerEnvVars( + final Map actualMap) { + return assertThat( + GenericKubernetesResource.>>get( + actualMap, "spec", "template", "spec", "containers", 0, "env")); + } + + private static ListAssert> assertInitContainerEnvVars( + final Map actualMap) { + return assertThat( + GenericKubernetesResource.>>get( + actualMap, "spec", "template", "spec", "initContainers", 0, "env")); + } +} 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 new file mode 100644 index 0000000000..e441516d46 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java @@ -0,0 +1,410 @@ +package io.javaoperatorsdk.operator.processing.dependent.kubernetes; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +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.DaemonSet; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.ReplicaSet; +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.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SSABasedGenericKubernetesResourceMatcherTest { + + private final Context mockedContext = mock(); + + private final SSABasedGenericKubernetesResourceMatcher matcher = + SSABasedGenericKubernetesResourceMatcher.getInstance(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setup() { + final var client = MockKubernetesClient.client(HasMetadata.class); + when(mockedContext.getClient()).thenReturn(client); + + final var configurationService = mock(ConfigurationService.class); + when(configurationService.shouldUseSSA(any(), any(), any())).thenReturn(true); + final var controllerConfiguration = mock(ControllerConfiguration.class); + when(controllerConfiguration.getConfigurationService()).thenReturn(configurationService); + when(controllerConfiguration.fieldManager()).thenReturn("controller"); + when(mockedContext.getControllerConfiguration()).thenReturn(controllerConfiguration); + } + + @Test + void noMatchWhenNoMatchingController() { + var desired = loadResource("nginx-deployment.yaml", Deployment.class); + var actual = + loadResource("deployment-with-managed-fields-additional-controller.yaml", Deployment.class); + actual + .getMetadata() + .getManagedFields() + .removeIf(managedFieldsEntry -> managedFieldsEntry.getManager().equals("controller")); + + assertThat(matcher.matches(actual, desired, mockedContext)).isFalse(); + } + + @Test + void exceptionWhenDuplicateController() { + var desired = loadResource("nginx-deployment.yaml", Deployment.class); + var actual = + loadResource("deployment-with-managed-fields-additional-controller.yaml", Deployment.class); + actual.getMetadata().getManagedFields().stream() + .filter(managedFieldsEntry -> managedFieldsEntry.getManager().equals("controller")) + .findFirst() + .ifPresent( + managedFieldsEntry -> actual.getMetadata().getManagedFields().add(managedFieldsEntry)); + + assertThatThrownBy(() -> matcher.matches(actual, desired, mockedContext)) + .isInstanceOf(OperatorException.class) + .hasMessage( + "More than one field manager exists with name: controller in resource: Deployment with" + + " name: test"); + } + + @Test + void matchWithSensitiveResource() { + var desired = loadResource("secret-desired.yaml", Secret.class); + var actual = loadResource("secret.yaml", Secret.class); + + assertThat(matcher.matches(actual, desired, mockedContext)).isTrue(); + } + + @Test + void noMatchWithSensitiveResource() { + var desired = loadResource("secret-desired.yaml", Secret.class); + var actual = loadResource("secret.yaml", Secret.class); + actual.getData().put("key1", "dmFsMg=="); + + assertThat(matcher.matches(actual, desired, mockedContext)).isFalse(); + } + + @Test + void checksIfAddsNotAddedByController() { + var desired = loadResource("nginx-deployment.yaml", Deployment.class); + var actual = + loadResource("deployment-with-managed-fields-additional-controller.yaml", Deployment.class); + + assertThat(matcher.matches(actual, desired, mockedContext)).isTrue(); + } + + @Test + void throwExceptionWhenManagedListEntryNotFound() { + var desired = loadResource("nginx-deployment.yaml", Deployment.class); + var actual = + loadResource("deployment-with-managed-fields-additional-controller.yaml", Deployment.class); + final var container = actual.getSpec().getTemplate().getSpec().getContainers().get(0); + container.setName("foobar"); + + assertThatThrownBy(() -> matcher.matches(actual, desired, mockedContext)) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "Cannot find list element for key: {\"name\":\"nginx\"} in map: [[image," + + " imagePullPolicy, name, ports, resources, terminationMessagePath," + + " terminationMessagePolicy]]"); + } + + @Test + void throwExceptionWhenDuplicateManagedListEntryFound() { + var desired = loadResource("nginx-deployment.yaml", Deployment.class); + var actual = + loadResource("deployment-with-managed-fields-additional-controller.yaml", Deployment.class); + final var container = actual.getSpec().getTemplate().getSpec().getContainers().get(0); + actual.getSpec().getTemplate().getSpec().getContainers().add(container); + + assertThatThrownBy(() -> matcher.matches(actual, desired, mockedContext)) + .isInstanceOf(IllegalStateException.class) + .hasMessage( + "More targets found in list element for key: {\"name\":\"nginx\"} in map: [[image," + + " imagePullPolicy, name, ports, resources, terminationMessagePath," + + " terminationMessagePolicy], [image, imagePullPolicy, name, ports, resources," + + " terminationMessagePath, terminationMessagePolicy]]"); + } + + // in the example the owner reference in a list is referenced by "k:", while all the fields are + // managed but not listed + @Test + void emptyListElementMatchesAllFields() { + var desiredConfigMap = + loadResource("configmap.empty-owner-reference-desired.yaml", ConfigMap.class); + var actualConfigMap = loadResource("configmap.empty-owner-reference.yaml", ConfigMap.class); + + assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isTrue(); + } + + // the whole "rules:" part is just implicitly managed + @Test + void wholeComplexFieldManaged() { + var desiredConfigMap = + loadResource("sample-whole-complex-part-managed-desired.yaml", ConfigMap.class); + var actualConfigMap = loadResource("sample-whole-complex-part-managed.yaml", ConfigMap.class); + + assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isTrue(); + } + + @Test + void multiItemList() { + var desiredConfigMap = loadResource("multi-container-pod-desired.yaml", ConfigMap.class); + var actualConfigMap = loadResource("multi-container-pod.yaml", ConfigMap.class); + + assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isTrue(); + } + + @Test + void changeValueInDesiredMakesMatchFail() { + var desiredConfigMap = + loadResource("configmap.empty-owner-reference-desired.yaml", ConfigMap.class); + desiredConfigMap.getData().put("key1", "different value"); + var actualConfigMap = loadResource("configmap.empty-owner-reference.yaml", ConfigMap.class); + + assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isFalse(); + } + + @Test + void changeValueActualMakesMatchFail() { + var desiredConfigMap = + loadResource("configmap.empty-owner-reference-desired.yaml", ConfigMap.class); + + var actualConfigMap = loadResource("configmap.empty-owner-reference.yaml", ConfigMap.class); + actualConfigMap.getData().put("key1", "different value"); + + assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isFalse(); + } + + @Test + void addedLabelInDesiredMakesMatchFail() { + var desiredConfigMap = + loadResource("configmap.empty-owner-reference-desired.yaml", ConfigMap.class); + desiredConfigMap.getMetadata().setLabels(Map.of("newlabel", "val")); + + var actualConfigMap = loadResource("configmap.empty-owner-reference.yaml", ConfigMap.class); + + assertThat(matcher.matches(actualConfigMap, desiredConfigMap, mockedContext)).isFalse(); + } + + @Test + void withFinalizer() { + var desired = loadResource("secret-with-finalizer-desired.yaml", Secret.class); + var actual = loadResource("secret-with-finalizer.yaml", Secret.class); + + assertThat(matcher.matches(actual, desired, mockedContext)).isTrue(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "sample-sts-volumeclaimtemplates-desired.yaml", + "sample-sts-volumeclaimtemplates-desired-with-status.yaml", + "sample-sts-volumeclaimtemplates-desired-with-volumemode.yaml" + }) + void testSanitizeState_statefulSetWithVolumeClaims(String desiredResourceFileName) { + var desiredStatefulSet = loadResource(desiredResourceFileName, StatefulSet.class); + var actualStatefulSet = loadResource("sample-sts-volumeclaimtemplates.yaml", StatefulSet.class); + + assertThat(matcher.matches(actualStatefulSet, desiredStatefulSet, mockedContext)).isTrue(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "sample-sts-volumeclaimtemplates-desired-add.yaml", + "sample-sts-volumeclaimtemplates-desired-update.yaml", + "sample-sts-volumeclaimtemplates-desired-with-status-mismatch.yaml", + "sample-sts-volumeclaimtemplates-desired-with-volumemode-mismatch.yaml" + }) + void testSanitizeState_statefulSetWithVolumeClaims_withMismatch(String desiredResourceFileName) { + var desiredStatefulSet = loadResource(desiredResourceFileName, StatefulSet.class); + var actualStatefulSet = loadResource("sample-sts-volumeclaimtemplates.yaml", StatefulSet.class); + + assertThat(matcher.matches(actualStatefulSet, desiredStatefulSet, mockedContext)).isFalse(); + } + + @Test + void testSanitizeState_statefulSetWithResources() { + var desiredStatefulSet = loadResource("sample-sts-resources-desired.yaml", StatefulSet.class); + var actualStatefulSet = loadResource("sample-sts-resources.yaml", StatefulSet.class); + + assertThat(matcher.matches(actualStatefulSet, desiredStatefulSet, mockedContext)).isTrue(); + } + + @Test + void testSanitizeState_statefulSetWithResources_withMismatch() { + var desiredStatefulSet = + loadResource("sample-sts-resources-desired-update.yaml", StatefulSet.class); + var actualStatefulSet = loadResource("sample-sts-resources.yaml", StatefulSet.class); + + assertThat(matcher.matches(actualStatefulSet, desiredStatefulSet, mockedContext)).isFalse(); + } + + @Test + void testSanitizeState_statefulSet_withResourceTypeMismatch() { + var desiredReplicaSet = loadResource("sample-rs-resources-desired.yaml", ReplicaSet.class); + var actualStatefulSet = loadResource("sample-sts-resources.yaml", StatefulSet.class); + + assertThat(matcher.matches(actualStatefulSet, desiredReplicaSet, mockedContext)).isFalse(); + } + + @Test + void testSanitizeState_deployment_withResourceTypeMismatch() { + var desiredReplicaSet = loadResource("sample-rs-resources-desired.yaml", ReplicaSet.class); + var actualDeployment = + loadResource("deployment-with-managed-fields-additional-controller.yaml", Deployment.class); + + assertThat(matcher.matches(actualDeployment, desiredReplicaSet, mockedContext)).isFalse(); + } + + @Test + void testSanitizeState_replicaSetWithResources() { + var desiredReplicaSet = loadResource("sample-rs-resources-desired.yaml", ReplicaSet.class); + var actualReplicaSet = loadResource("sample-rs-resources.yaml", ReplicaSet.class); + + assertThat(matcher.matches(actualReplicaSet, desiredReplicaSet, mockedContext)).isTrue(); + } + + @Test + void testSanitizeState_replicaSetWithResources_withMismatch() { + var desiredReplicaSet = + loadResource("sample-rs-resources-desired-update.yaml", ReplicaSet.class); + var actualReplicaSet = loadResource("sample-rs-resources.yaml", ReplicaSet.class); + + assertThat(matcher.matches(actualReplicaSet, desiredReplicaSet, mockedContext)).isFalse(); + } + + @Test + void testSanitizeState_replicaSet_withResourceTypeMismatch() { + var desiredDaemonSet = loadResource("sample-ds-resources-desired.yaml", DaemonSet.class); + var actualReplicaSet = loadResource("sample-rs-resources.yaml", ReplicaSet.class); + + assertThat(matcher.matches(actualReplicaSet, desiredDaemonSet, mockedContext)).isFalse(); + } + + @Test + void testSanitizeState_daemonSetWithResources() { + var desiredDaemonSet = loadResource("sample-ds-resources-desired.yaml", DaemonSet.class); + var actualDaemonSet = loadResource("sample-ds-resources.yaml", DaemonSet.class); + + assertThat(matcher.matches(actualDaemonSet, desiredDaemonSet, mockedContext)).isTrue(); + } + + @Test + void testSanitizeState_daemonSetWithResources_withMismatch() { + var desiredDaemonSet = loadResource("sample-ds-resources-desired-update.yaml", DaemonSet.class); + var actualDaemonSet = loadResource("sample-ds-resources.yaml", DaemonSet.class); + + assertThat(matcher.matches(actualDaemonSet, desiredDaemonSet, mockedContext)).isFalse(); + } + + @Test + void testSanitizeState_daemonSet_withResourceTypeMismatch() { + var desiredReplicaSet = loadResource("sample-rs-resources-desired.yaml", ReplicaSet.class); + var actualDaemonSet = loadResource("sample-ds-resources.yaml", DaemonSet.class); + + assertThat(matcher.matches(actualDaemonSet, desiredReplicaSet, mockedContext)).isFalse(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testCustomMatcher_returnsExpectedMatchBasedOnReadOnlyLabel(boolean readOnly) { + var dr = new ConfigMapDR(); + dr.configureWith( + new KubernetesDependentResourceConfigBuilder() + .withSSAMatcher(new ReadOnlyAwareMatcher<>()) + .build()); + var desiredConfigMap = + loadResource("configmap.empty-owner-reference-desired.yaml", ConfigMap.class); + desiredConfigMap.getData().put("key1", "another value"); + var actualConfigMap = loadResource("configmap.empty-owner-reference.yaml", ConfigMap.class); + actualConfigMap.getMetadata().getLabels().put("readonly", Boolean.toString(readOnly)); + + HasMetadata primary = mock(); + assertThat(dr.match(actualConfigMap, desiredConfigMap, primary, mockedContext).matched()) + .isEqualTo(readOnly); + } + + @Test + void keepOnlyManagedFields_withInvalidManagedFieldsKey() { + assertThatThrownBy( + () -> + SSABasedGenericKubernetesResourceMatcher.keepOnlyManagedFields( + Map.of(), + Map.of(), + Map.of("invalid", 1), + mockedContext.getClient().getKubernetesSerialization())) // + .isInstanceOf(IllegalStateException.class) // + .hasMessage("Key: invalid has no prefix: f:"); + } + + @Test + @SuppressWarnings("unchecked") + void testSortMap() { + final var unsortedMap = Map.of("b", Map.of("z", 26, "y", 25), "a", List.of("w", "v"), "c", 2); + + var sortedMap = SSABasedGenericKubernetesResourceMatcher.sortMap(unsortedMap); + assertThat(sortedMap.keySet()).containsExactly("a", "b", "c"); + + var sortedNestedMap = (Map) sortedMap.get("b"); + assertThat(sortedNestedMap.keySet()).containsExactly("y", "z"); + } + + @Test + @SuppressWarnings("unchecked") + void testSortListItems() { + final var unsortedList = + List.of(1, Map.of("z", 26, "y", 25), Map.of("b", 26, "c", 25, "a", 24), List.of("w", "v")); + + var sortedListItems = SSABasedGenericKubernetesResourceMatcher.sortListItems(unsortedList); + assertThat(sortedListItems).element(0).isEqualTo(1); + + var sortedNestedMap1 = (Map) sortedListItems.get(1); + assertThat(sortedNestedMap1.keySet()).containsExactly("y", "z"); + + var sortedNestedMap2 = (Map) sortedListItems.get(2); + assertThat(sortedNestedMap2.keySet()).containsExactly("a", "b", "c"); + } + + private static R loadResource(String fileName, Class clazz) { + return ReconcilerUtils.loadYaml( + clazz, SSABasedGenericKubernetesResourceMatcherTest.class, fileName); + } + + private static class ConfigMapDR extends KubernetesDependentResource { + public ConfigMapDR() { + super(ConfigMap.class); + } + } + + private static class ReadOnlyAwareMatcher + extends SSABasedGenericKubernetesResourceMatcher { + @Override + protected boolean matches( + Map actualMap, + Map desiredMap, + T actual, + T desired, + Context context) { + var readonly = actual.getMetadata().getLabels().get("readonly"); + if (readonly != null && readonly.equals("true")) { + return true; + } + return actualMap.equals(desiredMap); + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutorTest.java new file mode 100644 index 0000000000..bdb67d9bf6 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/AbstractWorkflowExecutorTest.java @@ -0,0 +1,135 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class AbstractWorkflowExecutorTest { + public static final String VALUE = "value"; + + protected TestDependent dr1 = new TestDependent("DR_1"); + protected TestDependent dr2 = new TestDependent("DR_2"); + protected TestDeleterDependent drDeleter = new TestDeleterDependent("DR_DELETER"); + protected TestErrorDependent drError = new TestErrorDependent("ERROR_1"); + protected TestErrorDeleterDependent errorDD = new TestErrorDeleterDependent("ERROR_DELETER"); + protected GarbageCollectedDeleter gcDeleter = new GarbageCollectedDeleter("GC_DELETER"); + + @SuppressWarnings("rawtypes") + protected final Condition notMetCondition = (primary, secondary, context) -> false; + + @SuppressWarnings("rawtypes") + protected final Condition metCondition = (primary, secondary, context) -> true; + + protected List executionHistory = + Collections.synchronizedList(new ArrayList<>()); + + public class TestDependent extends KubernetesDependentResource { + + public TestDependent(String name) { + super(ConfigMap.class, name); + } + + @Override + public ReconcileResult reconcile( + TestCustomResource primary, Context context) { + executionHistory.add(new ReconcileRecord(this)); + return ReconcileResult.resourceCreated( + new ConfigMapBuilder().addToBinaryData("key", VALUE).build()); + } + + @Override + public synchronized Optional> eventSource( + EventSourceContext context) { + var mockIES = mock(InformerEventSource.class); + when(mockIES.name()).thenReturn(name); + return Optional.of(mockIES); + } + + @Override + public String toString() { + return name(); + } + } + + public class TestDeleterDependent extends TestDependent + implements Creator, Deleter { + + public TestDeleterDependent(String name) { + super(name); + } + + @Override + public void delete(TestCustomResource primary, Context context) { + executionHistory.add(new ReconcileRecord(this, true)); + } + } + + public class GarbageCollectedDeleter extends TestDeleterDependent + implements GarbageCollected { + + public GarbageCollectedDeleter(String name) { + super(name); + } + } + + public class TestErrorDeleterDependent extends TestDependent + implements Deleter { + + public TestErrorDeleterDependent(String name) { + super(name); + } + + @Override + public void delete(TestCustomResource primary, Context context) { + executionHistory.add(new ReconcileRecord(this, true)); + throw new IllegalStateException("Test exception"); + } + } + + public class TestErrorDependent implements DependentResource { + private final String name; + + public TestErrorDependent(String name) { + this.name = name; + } + + @Override + public ReconcileResult reconcile( + TestCustomResource primary, Context context) { + executionHistory.add(new ReconcileRecord(this)); + throw new IllegalStateException("Test exception"); + } + + @Override + public Class resourceType() { + return String.class; + } + + @Override + public String name() { + return name; + } + + @Override + public String toString() { + return name; + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/BaseWorkflowResultTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/BaseWorkflowResultTest.java new file mode 100644 index 0000000000..fb8d56d975 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/BaseWorkflowResultTest.java @@ -0,0 +1,87 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.AggregatedOperatorException; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; + +import static org.assertj.core.api.Assertions.assertThat; + +class BaseWorkflowResultTest { + private static final BaseWorkflowResult.Detail detail = + new BaseWorkflowResult.Detail<>( + new RuntimeException(), null, null, null, null, null, false, false, false); + + @Test + void throwsExceptionWithoutNumberingIfAllDifferentClass() { + var res = new BaseWorkflowResult(Map.of(new DependentA(), detail, new DependentB(), detail)); + try { + res.throwAggregateExceptionIfErrorsPresent(); + } catch (AggregatedOperatorException e) { + assertThat(e.getAggregatedExceptions()) + .containsOnlyKeys(DependentA.class.getName(), DependentB.class.getName()); + } + } + + @Test + void numbersDependentClassNamesIfMoreOfSameType() { + var res = + new BaseWorkflowResult( + Map.of(new DependentA("name1"), detail, new DependentA("name2"), detail)); + try { + res.throwAggregateExceptionIfErrorsPresent(); + } catch (AggregatedOperatorException e) { + assertThat(e.getAggregatedExceptions()).hasSize(2); + } + } + + @SuppressWarnings("rawtypes") + static class DependentA implements DependentResource { + + private final String name; + + public DependentA() { + this(null); + } + + public DependentA(String name) { + this.name = name; + } + + @Override + public String name() { + if (name == null) { + return DependentResource.super.name(); + } + return name; + } + + @Override + public ReconcileResult reconcile(HasMetadata primary, Context context) { + return null; + } + + @Override + public Class resourceType() { + return null; + } + } + + @SuppressWarnings("rawtypes") + static class DependentB implements DependentResource { + @Override + public ReconcileResult reconcile(HasMetadata primary, Context context) { + return null; + } + + @Override + public Class resourceType() { + return null; + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationConditionTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationConditionTest.java new file mode 100644 index 0000000000..95afcc0464 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/CRDPresentActivationConditionTest.java @@ -0,0 +1,87 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.time.Duration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SuppressWarnings({"unchecked", "rawtypes"}) +class CRDPresentActivationConditionTest { + + public static final int TEST_CHECK_INTERVAL = 50; + public static final int TEST_CHECK_INTERVAL_WITH_SLACK = TEST_CHECK_INTERVAL + 10; + private final CRDPresentActivationCondition.CRDPresentChecker checkerMock = + mock(CRDPresentActivationCondition.CRDPresentChecker.class); + private final CRDPresentActivationCondition condition = + new CRDPresentActivationCondition(checkerMock, 2, Duration.ofMillis(TEST_CHECK_INTERVAL)); + private final DependentResource dr = + mock(DependentResource.class); + private final Context context = mock(Context.class); + + @BeforeEach + void setup() { + CRDPresentActivationCondition.clearState(); + when(checkerMock.checkIfCRDPresent(any(), any())).thenReturn(false); + when(dr.resourceType()).thenReturn(TestCustomResource.class); + } + + @Test + void checkCRDIfNotCheckedBefore() { + when(checkerMock.checkIfCRDPresent(any(), any())).thenReturn(true); + + assertThat(condition.isMet(dr, null, context)).isTrue(); + verify(checkerMock, times(1)).checkIfCRDPresent(any(), any()); + } + + @Test + void instantMetCallSkipsApiCall() { + condition.isMet(dr, null, context); + verify(checkerMock, times(1)).checkIfCRDPresent(any(), any()); + + condition.isMet(dr, null, context); + verify(checkerMock, times(1)).checkIfCRDPresent(any(), any()); + } + + @Test + void intervalExpiredAPICheckedAgain() throws InterruptedException { + condition.isMet(dr, null, context); + verify(checkerMock, times(1)).checkIfCRDPresent(any(), any()); + + Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK); + + condition.isMet(dr, null, context); + verify(checkerMock, times(2)).checkIfCRDPresent(any(), any()); + } + + @Test + void crdIsNotCheckedAnymoreIfIfOnceFound() throws InterruptedException { + when(checkerMock.checkIfCRDPresent(any(), any())).thenReturn(true); + + condition.isMet(dr, null, context); + verify(checkerMock, times(1)).checkIfCRDPresent(any(), any()); + + Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK); + + condition.isMet(dr, null, context); + verify(checkerMock, times(1)).checkIfCRDPresent(any(), any()); + } + + @Test + void crdNotCheckedAnymoreIfCountExpires() throws InterruptedException { + condition.isMet(dr, null, context); + Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK); + condition.isMet(dr, null, context); + Thread.sleep(TEST_CHECK_INTERVAL_WITH_SLACK); + condition.isMet(dr, null, context); + + verify(checkerMock, times(2)).checkIfCRDPresent(any(), any()); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ExecutionAssert.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ExecutionAssert.java new file mode 100644 index 0000000000..8429d0cf8e --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ExecutionAssert.java @@ -0,0 +1,92 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.assertj.core.api.AbstractAssert; + +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +public class ExecutionAssert extends AbstractAssert> { + + public ExecutionAssert(List reconcileRecords) { + super(reconcileRecords, ExecutionAssert.class); + } + + public static ExecutionAssert assertThat(List actual) { + return new ExecutionAssert(actual); + } + + public ExecutionAssert reconciled(DependentResource... dependentResources) { + for (int i = 0; i < dependentResources.length; i++) { + final DependentResource dr = dependentResources[i]; + var rr = getReconcileRecordFor(dr); + if (rr.isEmpty()) { + failWithMessage("Resource not reconciled: %s with index %d", dr, i); + } else { + if (rr.get().isDeleted()) { + failWithMessage("Resource deleted: %s with index %d", dr, i); + } + } + } + return this; + } + + public ExecutionAssert deleted(DependentResource... dependentResources) { + for (int i = 0; i < dependentResources.length; i++) { + final DependentResource dr = dependentResources[i]; + var rr = getReconcileRecordFor(dr); + if (rr.isEmpty()) { + failWithMessage("Resource not reconciled: %s with index %d", dr, i); + } else { + if (!rr.get().isDeleted()) { + failWithMessage("Resource not deleted: %s with index %d", dr, i); + } + } + } + return this; + } + + private List getActualDependentResources() { + return actual.stream().map(ReconcileRecord::getDependentResource).collect(Collectors.toList()); + } + + private Optional getReconcileRecordFor(DependentResource dependentResource) { + return actual.stream().filter(rr -> rr.getDependentResource() == dependentResource).findFirst(); + } + + public ExecutionAssert reconciledInOrder(DependentResource... dependentResources) { + if (dependentResources.length < 2) { + throw new IllegalArgumentException("At least two dependent resource needs to be specified"); + } + for (int i = 0; i < dependentResources.length - 1; i++) { + checkIfReconciled(i, dependentResources); + checkIfReconciled(i + 1, dependentResources); + if (getActualDependentResources().indexOf(dependentResources[i]) + > getActualDependentResources().indexOf(dependentResources[i + 1])) { + failWithMessage( + "Dependent resource on index %d reconciled after the one on index %d", i, i + 1); + } + } + + return this; + } + + public ExecutionAssert notReconciled(DependentResource... dependentResources) { + for (int i = 0; i < dependentResources.length; i++) { + final DependentResource dr = dependentResources[i]; + if (getActualDependentResources().contains(dr)) { + failWithMessage("Resource was reconciled: %s with index %d", dr, i); + } + } + return this; + } + + private void checkIfReconciled(int i, DependentResource[] dependentResources) { + final DependentResource dr = dependentResources[i]; + if (!getActualDependentResources().contains(dr)) { + failWithMessage("Dependent resource: %s, not reconciled on place %d", dr, i); + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowSupportTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowSupportTest.java new file mode 100644 index 0000000000..4b4a651637 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowSupportTest.java @@ -0,0 +1,178 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import org.assertj.core.data.Index; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; + +import static io.javaoperatorsdk.operator.processing.dependent.workflow.ManagedWorkflowTestUtils.createDRS; +import static org.assertj.core.api.Assertions.assertThat; + +class ManagedWorkflowSupportTest { + + public static final String NAME_1 = "name1"; + public static final String NAME_2 = "name2"; + public static final String NAME_3 = "name3"; + public static final String NAME_4 = "name4"; + + ManagedWorkflowSupport managedWorkflowSupport = new ManagedWorkflowSupport(); + + @Test + void trivialCasesNameDuplicates() { + managedWorkflowSupport.checkForNameDuplication(null); + managedWorkflowSupport.checkForNameDuplication(Collections.emptyList()); + managedWorkflowSupport.checkForNameDuplication(List.of(createDRS(NAME_1))); + managedWorkflowSupport.checkForNameDuplication(List.of(createDRS(NAME_1), createDRS(NAME_2))); + } + + @Test + void checkFindsDuplicates() { + final var drs2 = createDRS(NAME_2); + final var drs1 = createDRS(NAME_1); + + Assertions.assertThrows( + OperatorException.class, + () -> managedWorkflowSupport.checkForNameDuplication(List.of(drs2, drs2))); + + Assertions.assertThrows( + OperatorException.class, + () -> managedWorkflowSupport.checkForNameDuplication(List.of(drs1, drs2, drs2))); + + final var exception = + Assertions.assertThrows( + OperatorException.class, + () -> managedWorkflowSupport.checkForNameDuplication(List.of(drs1, drs2, drs2, drs1))); + assertThat(exception.getMessage()).contains(NAME_1, NAME_2); + } + + @Test + void orderingTrivialCases() { + assertThat(managedWorkflowSupport.orderAndDetectCycles(List.of(createDRS(NAME_1)))) + .map(DependentResourceSpec::getName) + .containsExactly(NAME_1); + + assertThat( + managedWorkflowSupport.orderAndDetectCycles( + List.of(createDRS(NAME_2, NAME_1), createDRS(NAME_1)))) + .map(DependentResourceSpec::getName) + .containsExactly(NAME_1, NAME_2); + } + + @Test + void orderingDiamondShape() { + String NAME_3 = "name3"; + String NAME_4 = "name4"; + + var res = + managedWorkflowSupport + .orderAndDetectCycles( + List.of( + createDRS(NAME_2, NAME_1), + createDRS(NAME_1), + createDRS(NAME_3, NAME_1), + createDRS(NAME_4, NAME_2, NAME_3))) + .stream() + .map(DependentResourceSpec::getName) + .collect(Collectors.toList()); + + assertThat(res) + .containsExactlyInAnyOrder(NAME_1, NAME_2, NAME_3, NAME_4) + .contains(NAME_1, Index.atIndex(0)) + .contains(NAME_4, Index.atIndex(3)); + } + + @Test + void orderingMultipleRoots() { + final var NAME_3 = "name3"; + final var NAME_4 = "name4"; + final var NAME_5 = "name5"; + final var NAME_6 = "name6"; + + var res = + managedWorkflowSupport + .orderAndDetectCycles( + List.of( + createDRS(NAME_2, NAME_1, NAME_5), + createDRS(NAME_1), + createDRS(NAME_3, NAME_1), + createDRS(NAME_4, NAME_2, NAME_3), + createDRS(NAME_5, NAME_1, NAME_6), + createDRS(NAME_6))) + .stream() + .map(DependentResourceSpec::getName) + .collect(Collectors.toList()); + + assertThat(res) + .containsExactlyInAnyOrder(NAME_1, NAME_5, NAME_6, NAME_2, NAME_3, NAME_4) + .contains(NAME_6, Index.atIndex(0)) + .contains(NAME_1, Index.atIndex(1)) + .contains(NAME_5, Index.atIndex(2)) + .contains(NAME_3, Index.atIndex(3)) + .contains(NAME_2, Index.atIndex(4)) + .contains(NAME_4, Index.atIndex(5)); + } + + @Test + void detectsCyclesTrivialCases() { + String NAME_3 = "name3"; + Assertions.assertThrows( + OperatorException.class, + () -> + managedWorkflowSupport.orderAndDetectCycles( + List.of(createDRS(NAME_2, NAME_1), createDRS(NAME_1, NAME_2)))); + Assertions.assertThrows( + OperatorException.class, + () -> + managedWorkflowSupport.orderAndDetectCycles( + List.of( + createDRS(NAME_2, NAME_1), + createDRS(NAME_1, NAME_3), + createDRS(NAME_3, NAME_2)))); + } + + @Test + void detectsCycleOnSubTree() { + + Assertions.assertThrows( + OperatorException.class, + () -> + managedWorkflowSupport.orderAndDetectCycles( + List.of( + createDRS(NAME_1), + createDRS(NAME_2, NAME_1), + createDRS(NAME_3, NAME_1, NAME_4), + createDRS(NAME_4, NAME_3)))); + + Assertions.assertThrows( + OperatorException.class, + () -> + managedWorkflowSupport.orderAndDetectCycles( + List.of( + createDRS(NAME_1), + createDRS(NAME_2, NAME_1, NAME_4), + createDRS(NAME_3, NAME_2), + createDRS(NAME_4, NAME_3)))); + } + + @Test + void createsWorkflow() { + var specs = + List.of( + createDRS(NAME_1), + createDRS(NAME_2, NAME_1), + createDRS(NAME_3, NAME_1), + createDRS(NAME_4, NAME_3, NAME_2)); + + var workflow = managedWorkflowSupport.createAsDefault(specs); + + assertThat(workflow.nodeNames()).containsExactlyInAnyOrder(NAME_1, NAME_2, NAME_3, NAME_4); + assertThat(workflow.getTopLevelResources()).containsExactly(NAME_1); + assertThat(workflow.getBottomLevelResources()).containsExactly(NAME_4); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowTest.java new file mode 100644 index 0000000000..1a46cec2f8 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowTest.java @@ -0,0 +1,91 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; + +import static io.javaoperatorsdk.operator.processing.dependent.workflow.ManagedWorkflowTestUtils.createDRS; +import static io.javaoperatorsdk.operator.processing.dependent.workflow.ManagedWorkflowTestUtils.createDRSWithTraits; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SuppressWarnings({"rawtypes"}) +class ManagedWorkflowTest { + + public static final String NAME = "name"; + + @Test + void checksIfWorkflowEmpty() { + assertThat(managedWorkflow().isEmpty()).isTrue(); + assertThat(managedWorkflow(createDRS(NAME)).isEmpty()).isFalse(); + } + + @Test + void isNotCleanerIfNoDeleter() { + assertThat(managedWorkflow(createDRS(NAME)).hasCleaner()).isFalse(); + } + + @Test + void isNotCleanerIfGarbageCollected() { + assertThat(managedWorkflow(createDRSWithTraits(NAME, GarbageCollected.class)).hasCleaner()) + .isFalse(); + } + + @Test + void isCleanerShouldWork() { + assertThat( + managedWorkflow( + createDRSWithTraits(NAME, GarbageCollected.class), + createDRSWithTraits("foo", Deleter.class)) + .hasCleaner()) + .isTrue(); + + assertThat( + managedWorkflow( + createDRSWithTraits("foo", Deleter.class), + createDRSWithTraits(NAME, GarbageCollected.class)) + .hasCleaner()) + .isTrue(); + } + + @Test + void isCleanerIfHasDeleter() { + var spec = createDRSWithTraits(NAME, Deleter.class); + assertThat(managedWorkflow(spec).hasCleaner()).isTrue(); + } + + @SuppressWarnings("unchecked") + ManagedWorkflow managedWorkflow(DependentResourceSpec... specs) { + final var configuration = mock(ControllerConfiguration.class); + + var ws = + new WorkflowSpec() { + @Override + public List getDependentResourceSpecs() { + return List.of(specs); + } + + @Override + public boolean isExplicitInvocation() { + return false; + } + + @Override + public boolean handleExceptionsInReconciler() { + return false; + } + }; + when(configuration.getWorkflowSpec()).thenReturn(Optional.of(ws)); + + return new BaseConfigurationService().getWorkflowFactory().workflowFor(configuration); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowTestUtils.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowTestUtils.java new file mode 100644 index 0000000000..58ca9a4c6e --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ManagedWorkflowTestUtils.java @@ -0,0 +1,42 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.Arrays; +import java.util.Set; + +import org.mockito.Mockito; + +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; +import io.javaoperatorsdk.operator.processing.dependent.EmptyTestDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; + +import static org.mockito.Mockito.*; + +@SuppressWarnings("rawtypes") +public class ManagedWorkflowTestUtils { + + @SuppressWarnings("unchecked") + public static DependentResourceSpec createDRS(String name, String... dependOns) { + return new DependentResourceSpec( + EmptyTestDependentResource.class, name, Set.of(dependOns), null, null, null, null, null); + } + + public static DependentResourceSpec createDRSWithTraits( + String name, Class... dependentResourceTraits) { + final var spy = Mockito.mock(DependentResourceSpec.class); + when(spy.getName()).thenReturn(name); + + Class toMock = DependentResource.class; + final var garbageCollected = + dependentResourceTraits != null + && Arrays.asList(dependentResourceTraits).contains(GarbageCollected.class); + if (garbageCollected) { + toMock = KubernetesDependentResource.class; + } + + final var dr = mock(toMock, withSettings().extraInterfaces(dependentResourceTraits)); + when(spy.getDependentResourceClass()).thenReturn(dr.getClass()); + return spy; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ReconcileRecord.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ReconcileRecord.java new file mode 100644 index 0000000000..ba7b4ecef9 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/ReconcileRecord.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; + +public class ReconcileRecord { + + private final DependentResource dependentResource; + private final boolean deleted; + + public ReconcileRecord(DependentResource dependentResource) { + this(dependentResource, false); + } + + public ReconcileRecord(DependentResource dependentResource, boolean deleted) { + this.dependentResource = dependentResource; + this.deleted = deleted; + } + + public DependentResource getDependentResource() { + return dependentResource; + } + + public boolean isDeleted() { + return deleted; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowBuilderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowBuilderTest.java new file mode 100644 index 0000000000..947cc8c630 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowBuilderTest.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class WorkflowBuilderTest { + + @Test + void workflowIsCleanerIfAtLeastOneDRIsCleaner() { + var dr = mock(DependentResource.class); + when(dr.name()).thenReturn("dr"); + var deleter = mock(DependentResource.class); + when(deleter.isDeletable()).thenReturn(true); + when(deleter.name()).thenReturn("deleter"); + + var workflow = + new WorkflowBuilder() + .addDependentResource(deleter) + .addDependentResource(dr) + .build(); + + assertThat(workflow.hasCleaner()).isTrue(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowCleanupExecutorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowCleanupExecutorTest.java new file mode 100644 index 0000000000..f8c1b3efad --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowCleanupExecutorTest.java @@ -0,0 +1,284 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.AggregatedOperatorException; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static io.javaoperatorsdk.operator.processing.dependent.workflow.ExecutionAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +class WorkflowCleanupExecutorTest extends AbstractWorkflowExecutorTest { + + protected TestDeleterDependent dd1 = new TestDeleterDependent("DR_DELETER_1"); + protected TestDeleterDependent dd2 = new TestDeleterDependent("DR_DELETER_2"); + protected TestDeleterDependent dd3 = new TestDeleterDependent("DR_DELETER_3"); + protected TestDeleterDependent dd4 = new TestDeleterDependent("DR_DELETER_4"); + + @SuppressWarnings("unchecked") + Context mockContext = spy(Context.class); + + ExecutorService executorService = Executors.newCachedThreadPool(); + + @BeforeEach + void setup() { + var eventSourceContextMock = mock(EventSourceContext.class); + var eventSourceRetrieverMock = mock(EventSourceRetriever.class); + var mockControllerConfig = mock(ControllerConfiguration.class); + when(eventSourceRetrieverMock.eventSourceContextForDynamicRegistration()) + .thenReturn(eventSourceContextMock); + var client = MockKubernetesClient.client(ConfigMap.class); + when(eventSourceContextMock.getClient()).thenReturn(client); + when(eventSourceContextMock.getControllerConfiguration()).thenReturn(mockControllerConfig); + when(mockControllerConfig.getConfigurationService()) + .thenReturn(mock(ConfigurationService.class)); + when(mockContext.managedWorkflowAndDependentResourceContext()) + .thenReturn(mock(ManagedWorkflowAndDependentResourceContext.class)); + when(mockContext.getWorkflowExecutorService()).thenReturn(executorService); + when(mockContext.eventSourceRetriever()).thenReturn(eventSourceRetrieverMock); + } + + @Test + void cleanUpDiamondWorkflow() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dd1) + .addDependentResourceAndConfigure(dr1) + .dependsOn(dd1) + .addDependentResourceAndConfigure(dd2) + .dependsOn(dd1) + .addDependentResourceAndConfigure(dd3) + .dependsOn(dr1, dd2) + .build(); + + var res = workflow.cleanup(new TestCustomResource(), mockContext); + + assertThat(executionHistory).reconciledInOrder(dd3, dd2, dd1).notReconciled(dr1); + + Assertions.assertThat(res.getDeleteCalledOnDependents()) + .containsExactlyInAnyOrder(dd1, dd2, dd3); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getPostConditionNotMetDependents()).isEmpty(); + } + + @Test + void dontDeleteIfDependentErrored() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dd1) + .addDependentResourceAndConfigure(dd2) + .dependsOn(dd1) + .addDependentResourceAndConfigure(dd3) + .dependsOn(dd2) + .addDependentResourceAndConfigure(errorDD) + .dependsOn(dd2) + .withThrowExceptionFurther(false) + .build(); + + var res = workflow.cleanup(new TestCustomResource(), mockContext); + assertThrows(AggregatedOperatorException.class, res::throwAggregateExceptionIfErrorsPresent); + + assertThat(executionHistory).deleted(dd3, errorDD).notReconciled(dd1, dd2); + + Assertions.assertThat(res.getDeleteCalledOnDependents()).containsExactlyInAnyOrder(dd3); + Assertions.assertThat(res.getErroredDependents()).containsOnlyKeys(errorDD); + Assertions.assertThat(res.getPostConditionNotMetDependents()).isEmpty(); + } + + @Test + void cleanupConditionTrivialCase() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dd1) + .addDependentResourceAndConfigure(dd2) + .dependsOn(dd1) + .withDeletePostcondition(notMetCondition) + .build(); + + var res = workflow.cleanup(new TestCustomResource(), mockContext); + + assertThat(executionHistory).deleted(dd2).notReconciled(dd1); + Assertions.assertThat(res.getDeleteCalledOnDependents()).containsExactlyInAnyOrder(dd2); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getPostConditionNotMetDependents()).containsExactlyInAnyOrder(dd2); + } + + @Test + void cleanupConditionMet() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dd1) + .addDependentResourceAndConfigure(dd2) + .dependsOn(dd1) + .withDeletePostcondition(metCondition) + .build(); + + var res = workflow.cleanup(new TestCustomResource(), mockContext); + + assertThat(executionHistory).deleted(dd2, dd1); + + Assertions.assertThat(res.getDeleteCalledOnDependents()).containsExactlyInAnyOrder(dd1, dd2); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getPostConditionNotMetDependents()).isEmpty(); + } + + @Test + void cleanupConditionDiamondWorkflow() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dd1) + .addDependentResourceAndConfigure(dd2) + .dependsOn(dd1) + .addDependentResourceAndConfigure(dd3) + .dependsOn(dd1) + .withDeletePostcondition(notMetCondition) + .addDependentResourceAndConfigure(dd4) + .dependsOn(dd2, dd3) + .build(); + + var res = workflow.cleanup(new TestCustomResource(), mockContext); + + assertThat(executionHistory) + .reconciledInOrder(dd4, dd2) + .reconciledInOrder(dd4, dd3) + .notReconciled(dr1); + + Assertions.assertThat(res.getDeleteCalledOnDependents()) + .containsExactlyInAnyOrder(dd4, dd3, dd2); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getPostConditionNotMetDependents()).containsExactlyInAnyOrder(dd3); + } + + @Test + void dontDeleteIfGarbageCollected() { + var workflow = + new WorkflowBuilder().addDependentResource(gcDeleter).build(); + + var res = workflow.cleanup(new TestCustomResource(), mockContext); + + assertThat(executionHistory).notReconciled(gcDeleter); + + Assertions.assertThat(res.getDeleteCalledOnDependents()).isEmpty(); + } + + @Test + void ifDependentActiveDependentNormallyDeleted() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dd1) + .addDependentResourceAndConfigure(dd2) + .dependsOn(dd1) + .addDependentResourceAndConfigure(dd3) + .dependsOn(dd1) + .withActivationCondition(metCondition) + .addDependentResourceAndConfigure(dd4) + .dependsOn(dd2, dd3) + .build(); + + var res = workflow.cleanup(new TestCustomResource(), mockContext); + + assertThat(executionHistory).reconciledInOrder(dd4, dd2, dd1).reconciledInOrder(dd4, dd3, dd1); + + Assertions.assertThat(res.getDeleteCalledOnDependents()) + .containsExactlyInAnyOrder(dd4, dd3, dd2, dd1); + } + + @Test + void ifDependentActiveDeletePostConditionIsChecked() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dd1) + .addDependentResourceAndConfigure(dd2) + .dependsOn(dd1) + .addDependentResourceAndConfigure(dd3) + .dependsOn(dd1) + .withDeletePostcondition(notMetCondition) + .withActivationCondition(metCondition) + .addDependentResourceAndConfigure(dd4) + .dependsOn(dd2, dd3) + .build(); + + var res = workflow.cleanup(new TestCustomResource(), mockContext); + + assertThat(executionHistory) + .reconciledInOrder(dd4, dd2) + .reconciledInOrder(dd4, dd3) + .notReconciled(dr1); + + Assertions.assertThat(res.getDeleteCalledOnDependents()) + .containsExactlyInAnyOrder(dd4, dd3, dd2); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getPostConditionNotMetDependents()).containsExactlyInAnyOrder(dd3); + } + + @Test + void ifDependentInactiveDeleteIsNotCalled() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dd1) + .addDependentResourceAndConfigure(dd2) + .dependsOn(dd1) + .addDependentResourceAndConfigure(dd3) + .dependsOn(dd1) + .withActivationCondition(notMetCondition) + .addDependentResourceAndConfigure(dd4) + .dependsOn(dd2, dd3) + .build(); + + var res = workflow.cleanup(new TestCustomResource(), mockContext); + + assertThat(executionHistory).reconciledInOrder(dd4, dd2, dd1); + + Assertions.assertThat(res.getDeleteCalledOnDependents()) + .containsExactlyInAnyOrder(dd4, dd2, dd1); + } + + @Test + void ifDependentInactiveDeletePostConditionNotChecked() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dd1) + .addDependentResourceAndConfigure(dd2) + .dependsOn(dd1) + .addDependentResourceAndConfigure(dd3) + .dependsOn(dd1) + .withDeletePostcondition(notMetCondition) + .withActivationCondition(notMetCondition) + .addDependentResourceAndConfigure(dd4) + .dependsOn(dd2, dd3) + .build(); + + var res = workflow.cleanup(new TestCustomResource(), mockContext); + + assertThat(executionHistory).reconciledInOrder(dd4, dd2, dd1); + + Assertions.assertThat(res.getPostConditionNotMetDependents()).isEmpty(); + } + + @Test + void singleInactiveDependent() { + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dd1) + .withActivationCondition(notMetCondition) + .build(); + + workflow.cleanup(new TestCustomResource(), mockContext); + + assertThat(executionHistory).notReconciled(dd1); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileExecutorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileExecutorTest.java new file mode 100644 index 0000000000..f0c5c64e44 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowReconcileExecutorTest.java @@ -0,0 +1,767 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.AggregatedOperatorException; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext; +import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static io.javaoperatorsdk.operator.processing.dependent.workflow.ExecutionAssert.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class WorkflowReconcileExecutorTest extends AbstractWorkflowExecutorTest { + private static final Logger log = LoggerFactory.getLogger(WorkflowReconcileExecutorTest.class); + + @SuppressWarnings("unchecked") + Context mockContext = spy(Context.class); + + ExecutorService executorService = Executors.newCachedThreadPool(); + + TestDependent dr3 = new TestDependent("DR_3"); + TestDependent dr4 = new TestDependent("DR_4"); + + @BeforeEach + @SuppressWarnings("unchecked") + void setup(TestInfo testInfo) { + log.debug("==> Starting test {}", testInfo.getDisplayName()); + when(mockContext.managedWorkflowAndDependentResourceContext()) + .thenReturn(mock(ManagedWorkflowAndDependentResourceContext.class)); + when(mockContext.getWorkflowExecutorService()).thenReturn(executorService); + when(mockContext.eventSourceRetriever()).thenReturn(mock(EventSourceRetriever.class)); + } + + @Test + void reconcileTopLevelResources() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResource(dr2) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory).reconciled(dr1, dr2); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1, dr2); + } + + @Test + void reconciliationWithSimpleDependsOn() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + assertThat(executionHistory).reconciledInOrder(dr1, dr2); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1, dr2); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void reconciliationWithTwoTheDependsOns() { + + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .addDependentResourceAndConfigure(dr3) + .dependsOn(dr1) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + assertThat(executionHistory).reconciledInOrder(dr1, dr2).reconciledInOrder(dr1, dr3); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1, dr2, dr3); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void diamondShareWorkflowReconcile() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .addDependentResourceAndConfigure(dr3) + .dependsOn(dr1) + .addDependentResourceAndConfigure(dr4) + .dependsOn(dr3) + .dependsOn(dr2) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + assertThat(executionHistory).reconciledInOrder(dr1, dr2, dr4).reconciledInOrder(dr1, dr3, dr4); + + Assertions.assertThat(res.getReconciledDependents()) + .containsExactlyInAnyOrder(dr1, dr2, dr3, dr4); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void exceptionHandlingSimpleCases() { + var workflow = + new WorkflowBuilder() + .addDependentResource(drError) + .withThrowExceptionFurther(false) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThrows(AggregatedOperatorException.class, res::throwAggregateExceptionIfErrorsPresent); + + assertThat(executionHistory).reconciled(drError); + Assertions.assertThat(res.getErroredDependents()).containsOnlyKeys(drError); + Assertions.assertThat(res.getReconciledDependents()).isEmpty(); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void dependentsOnErroredResourceNotReconciled() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResourceAndConfigure(drError) + .dependsOn(dr1) + .addDependentResourceAndConfigure(dr2) + .dependsOn(drError) + .withThrowExceptionFurther(false) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + assertThrows(AggregatedOperatorException.class, res::throwAggregateExceptionIfErrorsPresent); + + assertThat(executionHistory).reconciled(dr1, drError).notReconciled(dr2); + Assertions.assertThat(res.getErroredDependents()).containsOnlyKeys(drError); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void oneBranchErrorsOtherCompletes() { + + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResourceAndConfigure(drError) + .dependsOn(dr1) + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .addDependentResourceAndConfigure(dr3) + .dependsOn(dr2) + .withThrowExceptionFurther(false) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + assertThrows(AggregatedOperatorException.class, res::throwAggregateExceptionIfErrorsPresent); + + assertThat(executionHistory).reconciledInOrder(dr1, dr2, dr3).reconciledInOrder(dr1, drError); + Assertions.assertThat(res.getErroredDependents()).containsOnlyKeys(drError); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1, dr2, dr3); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void onlyOneDependsOnErroredResourceNotReconciled() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResource(drError) + .addDependentResourceAndConfigure(dr2) + .dependsOn(drError, dr1) + .withThrowExceptionFurther(false) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + assertThrows(AggregatedOperatorException.class, res::throwAggregateExceptionIfErrorsPresent); + + assertThat(executionHistory).notReconciled(dr2); + Assertions.assertThat(res.getErroredDependents()).containsKey(drError); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void simpleReconcileCondition() { + final var result = "Some error message"; + final var unmetWithResult = + new DetailedCondition() { + @Override + public Result detailedIsMet( + DependentResource dependentResource, + TestCustomResource primary, + Context context) { + return Result.withResult(false, result); + } + }; + + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withReconcilePrecondition(unmetWithResult) + .addDependentResourceAndConfigure(dr2) + .withReconcilePrecondition(metCondition) + .addDependentResourceAndConfigure(drDeleter) + .withReconcilePrecondition(notMetCondition) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory).notReconciled(dr1).reconciled(dr2).deleted(drDeleter); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr2); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + res.getDependentConditionResult(dr1, Condition.Type.RECONCILE, String.class) + .ifPresentOrElse(s -> assertEquals(result, s), org.junit.jupiter.api.Assertions::fail); + } + + @Test + void triangleOnceConditionNotMet() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .addDependentResourceAndConfigure(drDeleter) + .withReconcilePrecondition(notMetCondition) + .dependsOn(dr1) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory).reconciledInOrder(dr1, dr2).deleted(drDeleter); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1, dr2); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void reconcileConditionTransitiveDelete() { + TestDeleterDependent drDeleter2 = new TestDeleterDependent("DR_DELETER_2"); + + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .withReconcilePrecondition(notMetCondition) + .addDependentResourceAndConfigure(drDeleter) + .dependsOn(dr2) + .withReconcilePrecondition(metCondition) + .addDependentResourceAndConfigure(drDeleter2) + .dependsOn(drDeleter) + .withReconcilePrecondition(metCondition) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + assertThat(executionHistory).notReconciled(dr2); + assertThat(executionHistory).reconciledInOrder(dr1, drDeleter2, drDeleter); + assertThat(executionHistory).deleted(drDeleter2, drDeleter); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void reconcileConditionAlsoErrorDependsOn() { + TestDeleterDependent drDeleter2 = new TestDeleterDependent("DR_DELETER_2"); + + var workflow = + new WorkflowBuilder() + .addDependentResource(drError) + .addDependentResourceAndConfigure(drDeleter) + .withReconcilePrecondition(notMetCondition) + .addDependentResourceAndConfigure(drDeleter2) + .dependsOn(drError, drDeleter) + .withReconcilePrecondition(metCondition) + .withThrowExceptionFurther(false) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + assertThrows(AggregatedOperatorException.class, res::throwAggregateExceptionIfErrorsPresent); + + assertThat(executionHistory).deleted(drDeleter2, drDeleter).reconciled(drError); + + Assertions.assertThat(res.getErroredDependents()).containsOnlyKeys(drError); + Assertions.assertThat(res.getReconciledDependents()).isEmpty(); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void oneDependsOnConditionNotMet() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResourceAndConfigure(dr2) + .withReconcilePrecondition(notMetCondition) + .addDependentResourceAndConfigure(drDeleter) + .dependsOn(dr1, dr2) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + + assertThat(executionHistory).deleted(drDeleter).notReconciled(dr2).reconciled(dr1); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void deletedIfReconcileConditionNotMet() { + TestDeleterDependent drDeleter2 = new TestDeleterDependent("DR_DELETER_2"); + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResourceAndConfigure(drDeleter) + .dependsOn(dr1) + .withReconcilePrecondition(notMetCondition) + .addDependentResourceAndConfigure(drDeleter2) + .dependsOn(dr1, drDeleter) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory) + .reconciledInOrder(dr1, drDeleter2, drDeleter) + .deleted(drDeleter2, drDeleter); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void deleteDoneInReverseOrder() { + TestDeleterDependent drDeleter2 = new TestDeleterDependent("DR_DELETER_2"); + TestDeleterDependent drDeleter3 = new TestDeleterDependent("DR_DELETER_3"); + TestDeleterDependent drDeleter4 = new TestDeleterDependent("DR_DELETER_4"); + + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResourceAndConfigure(drDeleter) + .withReconcilePrecondition(notMetCondition) + .dependsOn(dr1) + .addDependentResourceAndConfigure(drDeleter2) + .dependsOn(drDeleter) + .addDependentResourceAndConfigure(drDeleter3) + .dependsOn(drDeleter) + .addDependentResourceAndConfigure(drDeleter4) + .dependsOn(drDeleter3) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory) + .reconciledInOrder(dr1, drDeleter4, drDeleter3, drDeleter) + .reconciledInOrder(dr1, drDeleter2, drDeleter) + .deleted(drDeleter, drDeleter2, drDeleter3, drDeleter4); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void diamondDeleteWithPostConditionInMiddle() { + TestDeleterDependent drDeleter2 = new TestDeleterDependent("DR_DELETER_2"); + TestDeleterDependent drDeleter3 = new TestDeleterDependent("DR_DELETER_3"); + TestDeleterDependent drDeleter4 = new TestDeleterDependent("DR_DELETER_4"); + + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(drDeleter) + .withReconcilePrecondition(notMetCondition) + .addDependentResourceAndConfigure(drDeleter2) + .dependsOn(drDeleter) + .addDependentResourceAndConfigure(drDeleter3) + .dependsOn(drDeleter) + .withDeletePostcondition(this.notMetCondition) + .addDependentResourceAndConfigure(drDeleter4) + .dependsOn(drDeleter3, drDeleter2) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory) + .notReconciled(drDeleter) + .reconciledInOrder(drDeleter4, drDeleter2) + .reconciledInOrder(drDeleter4, drDeleter3); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).isEmpty(); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void diamondDeleteErrorInMiddle() { + TestDeleterDependent drDeleter2 = new TestDeleterDependent("DR_DELETER_2"); + TestDeleterDependent drDeleter3 = new TestDeleterDependent("DR_DELETER_3"); + + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(drDeleter) + .withReconcilePrecondition(notMetCondition) + .addDependentResourceAndConfigure(drDeleter2) + .dependsOn(drDeleter) + .addDependentResourceAndConfigure(errorDD) + .dependsOn(drDeleter) + .addDependentResourceAndConfigure(drDeleter3) + .dependsOn(errorDD, drDeleter2) + .withThrowExceptionFurther(false) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory) + .notReconciled(drDeleter, drError) + .reconciledInOrder(drDeleter3, drDeleter2); + + Assertions.assertThat(res.getErroredDependents()).containsOnlyKeys(errorDD); + Assertions.assertThat(res.getReconciledDependents()).isEmpty(); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void readyConditionTrivialCase() { + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withReadyPostcondition(metCondition) + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory).reconciledInOrder(dr1, dr2); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1, dr2); + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + void readyConditionNotMetTrivialCase() { + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withReadyPostcondition(notMetCondition) + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory).reconciled(dr1).notReconciled(dr2); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1); + Assertions.assertThat(res.getNotReadyDependents()).containsExactlyInAnyOrder(dr1); + } + + @Test + void readyConditionNotMetInOneParent() { + + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withReadyPostcondition(notMetCondition) + .addDependentResource(dr2) + .addDependentResourceAndConfigure(dr3) + .dependsOn(dr1, dr2) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory).reconciled(dr1, dr2).notReconciled(dr3); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1, dr2); + Assertions.assertThat(res.getNotReadyDependents()).containsExactlyInAnyOrder(dr1); + } + + @Test + void diamondShareWithReadyCondition() { + var workflow = + new WorkflowBuilder() + .addDependentResource(dr1) + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .withReadyPostcondition(notMetCondition) + .addDependentResourceAndConfigure(dr3) + .dependsOn(dr1) + .addDependentResourceAndConfigure(dr4) + .dependsOn(dr2, dr3) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + assertThat(executionHistory) + .reconciledInOrder(dr1, dr2) + .reconciledInOrder(dr1, dr3) + .notReconciled(dr4); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).containsExactlyInAnyOrder(dr1, dr2, dr3); + Assertions.assertThat(res.getNotReadyDependents()).containsExactlyInAnyOrder(dr2); + } + + @Test + void garbageCollectedResourceIsDeletedIfReconcilePreconditionDoesNotHold() { + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(gcDeleter) + .withReconcilePrecondition(notMetCondition) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + assertThat(executionHistory).deleted(gcDeleter); + } + + @Test + void garbageCollectedDeepResourceIsDeletedIfReconcilePreconditionDoesNotHold() { + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withReconcilePrecondition(notMetCondition) + .addDependentResourceAndConfigure(gcDeleter) + .dependsOn(dr1) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + assertThat(executionHistory).deleted(gcDeleter); + } + + @Test + void notReconciledIfActivationConditionNotMet() { + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withActivationCondition(notMetCondition) + .addDependentResource(dr2) + .build(); + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory).reconciled(dr2).notReconciled(dr1); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).contains(dr2); + } + + @Test + void dependentsOnANonActiveDependentNotReconciled() { + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withActivationCondition(notMetCondition) + .addDependentResource(dr2) + .addDependentResourceAndConfigure(dr3) + .dependsOn(dr1) + .build(); + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory).reconciled(dr2).notReconciled(dr1, dr3); + Assertions.assertThat(res.getErroredDependents()).isEmpty(); + Assertions.assertThat(res.getReconciledDependents()).contains(dr2); + } + + @Test + void readyConditionNotCheckedOnNonActiveDependent() { + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withActivationCondition(notMetCondition) + .withReadyPostcondition(notMetCondition) + .addDependentResource(dr2) + .addDependentResourceAndConfigure(dr3) + .dependsOn(dr1) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + Assertions.assertThat(res.getNotReadyDependents()).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + void reconcilePreconditionNotCheckedOnNonActiveDependent() { + var precondition = mock(Condition.class); + + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withActivationCondition(notMetCondition) + .withReconcilePrecondition(precondition) + .build(); + + workflow.reconcile(new TestCustomResource(), mockContext); + + verify(precondition, never()).isMet(any(), any(), any()); + } + + @Test + void deletesDependentsOfNonActiveDependentButNotTheNonActive() { + TestDeleterDependent drDeleter2 = new TestDeleterDependent("DR_DELETER_2"); + TestDeleterDependent drDeleter3 = new TestDeleterDependent("DR_DELETER_3"); + + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withActivationCondition(notMetCondition) + .addDependentResourceAndConfigure(drDeleter) + .dependsOn(dr1) + .addDependentResourceAndConfigure(drDeleter2) + .dependsOn(drDeleter) + .withActivationCondition(notMetCondition) + .addDependentResourceAndConfigure(drDeleter3) + .dependsOn(drDeleter2) + .build(); + + var res = workflow.reconcile(new TestCustomResource(), mockContext); + + Assertions.assertThat(res.getReconciledDependents()).isEmpty(); + assertThat(executionHistory).deleted(drDeleter, drDeleter3).notReconciled(dr1, drDeleter2); + } + + @Test + @SuppressWarnings("unchecked") + void activationConditionOnlyCalledOnceOnDeleteDependents() { + TestDeleterDependent drDeleter2 = new TestDeleterDependent("DR_DELETER_2"); + var condition = mock(Condition.class); + when(condition.isMet(any(), any(), any())).thenReturn(false); + + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(drDeleter) + .withActivationCondition(condition) + .addDependentResourceAndConfigure(drDeleter2) + .dependsOn(drDeleter) + .build(); + + workflow.reconcile(new TestCustomResource(), mockContext); + + assertThat(executionHistory).deleted(drDeleter2); + verify(condition, times(1)).isMet(any(), any(), any()); + } + + @Test + void resultFromReadyConditionShouldBeAvailableIfExisting() { + final var result = Integer.valueOf(42); + final var resultCondition = + new DetailedCondition<>() { + @Override + public Result detailedIsMet( + DependentResource dependentResource, + HasMetadata primary, + Context context) { + return new Result<>() { + @Override + public Object getDetail() { + return result; + } + + @Override + public boolean isSuccess() { + return false; // force not ready + } + }; + } + }; + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withReadyPostcondition(resultCondition) + .build(); + + final var reconcileResult = workflow.reconcile(new TestCustomResource(), mockContext); + assertEquals( + result, reconcileResult.getNotReadyDependentResult(dr1, Integer.class).orElseThrow()); + } + + @Test + void shouldThrowIllegalArgumentExceptionIfTypesDoNotMatch() { + final var result = "FOO"; + final var resultCondition = + new DetailedCondition<>() { + @Override + public Result detailedIsMet( + DependentResource dependentResource, + HasMetadata primary, + Context context) { + return new Result<>() { + @Override + public Object getDetail() { + return result; + } + + @Override + public boolean isSuccess() { + return false; // force not ready + } + }; + } + }; + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withReadyPostcondition(resultCondition) + .build(); + + final var reconcileResult = workflow.reconcile(new TestCustomResource(), mockContext); + final var expectedResultType = Integer.class; + final var e = + assertThrows( + IllegalArgumentException.class, + () -> reconcileResult.getNotReadyDependentResult(dr1, expectedResultType)); + final var message = e.getMessage(); + assertTrue(message.contains(dr1.name())); + assertTrue(message.contains(expectedResultType.getSimpleName())); + assertTrue(message.contains(result)); + } + + @Test + void shouldReturnEmptyIfNoConditionResultExists() { + var workflow = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .withReadyPostcondition(notMetCondition) + .build(); + + final var reconcileResult = workflow.reconcile(new TestCustomResource(), mockContext); + assertTrue(reconcileResult.getNotReadyDependentResult(dr1, Integer.class).isEmpty()); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowTest.java new file mode 100644 index 0000000000..e2bd4a5b62 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/workflow/WorkflowTest.java @@ -0,0 +1,113 @@ +package io.javaoperatorsdk.operator.processing.dependent.workflow; + +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +@SuppressWarnings("rawtypes") +class WorkflowTest { + + @Test + void zeroTopLevelDRShouldThrowException() { + var dr1 = mockDependent("dr1"); + var dr2 = mockDependent("dr2"); + var dr3 = mockDependent("dr3"); + + var cyclicWorkflowBuilderSetup = + new WorkflowBuilder() + .addDependentResourceAndConfigure(dr1) + .dependsOn() + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .addDependentResourceAndConfigure(dr3) + .dependsOn(dr2) + .addDependentResourceAndConfigure(dr1) + .dependsOn(dr2); + + assertThrows(IllegalStateException.class, cyclicWorkflowBuilderSetup::build); + } + + @Test + void calculatesTopLevelResources() { + var dr1 = mockDependent("dr1"); + var dr2 = mockDependent("dr2"); + var independentDR = mockDependent("independentDR"); + + var workflow = + new WorkflowBuilder() + .addDependentResource(independentDR) + .addDependentResource(dr1) + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .buildAsDefaultWorkflow(); + + Set topResources = + workflow.getTopLevelDependentResources().stream() + .map(DependentResourceNode::getDependentResource) + .collect(Collectors.toSet()); + + assertThat(topResources).containsExactlyInAnyOrder(dr1, independentDR); + } + + @Test + void calculatesBottomLevelResources() { + var dr1 = mockDependent("dr1"); + var dr2 = mockDependent("dr2"); + var independentDR = mockDependent("independentDR"); + + final var workflow = + new WorkflowBuilder() + .addDependentResource(independentDR) + .addDependentResource(dr1) + .addDependentResourceAndConfigure(dr2) + .dependsOn(dr1) + .buildAsDefaultWorkflow(); + + Set bottomResources = + workflow.getBottomLevelDependentResources().stream() + .map(DependentResourceNode::getDependentResource) + .collect(Collectors.toSet()); + + assertThat(bottomResources).containsExactlyInAnyOrder(dr2, independentDR); + } + + @Test + void isDeletableShouldWork() { + var dr = mock(DependentResource.class); + assertFalse(DefaultWorkflow.isDeletable(dr.getClass())); + + dr = mock(DependentResource.class, withSettings().extraInterfaces(Deleter.class)); + assertTrue(DefaultWorkflow.isDeletable(dr.getClass())); + + dr = mock(KubernetesDependentResource.class); + assertFalse(DefaultWorkflow.isDeletable(dr.getClass())); + + dr = mock(KubernetesDependentResource.class, withSettings().extraInterfaces(Deleter.class)); + assertTrue(DefaultWorkflow.isDeletable(dr.getClass())); + + dr = + mock( + KubernetesDependentResource.class, + withSettings().extraInterfaces(Deleter.class, GarbageCollected.class)); + assertFalse(DefaultWorkflow.isDeletable(dr.getClass())); + } + + static DependentResource mockDependent(String name) { + var res = mock(DependentResource.class); + when(res.name()).thenReturn(name); + return res; + } +} 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 new file mode 100644 index 0000000000..9819eb7ee9 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java @@ -0,0 +1,544 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.internal.stubbing.answers.AnswersWithDelay; +import org.mockito.internal.stubbing.answers.Returns; +import org.mockito.stubbing.Answer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; +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.controller.ControllerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceAction; +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; +import io.javaoperatorsdk.operator.processing.event.source.timer.TimerEventSource; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; +import io.javaoperatorsdk.operator.processing.retry.GradualRetry; +import io.javaoperatorsdk.operator.processing.retry.Retry; +import io.javaoperatorsdk.operator.processing.retry.RetryExecution; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static io.javaoperatorsdk.operator.TestUtils.markForDeletion; +import static io.javaoperatorsdk.operator.TestUtils.testCustomResource; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.after; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.atMostOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SuppressWarnings({"rawtypes", "unchecked"}) +class EventProcessorTest { + + private static final Logger log = LoggerFactory.getLogger(EventProcessorTest.class); + + public static final int FAKE_CONTROLLER_EXECUTION_DURATION = 250; + public static final int SEPARATE_EXECUTION_TIMEOUT = 450; + public static final String TEST_NAMESPACE = "default-event-handler-test"; + public static final int TIME_TO_WAIT_AFTER_SUBMISSION_BEFORE_EXECUTION = 150; + public static final int DISPATCHING_DELAY = 250; + + private final ReconciliationDispatcher reconciliationDispatcherMock = + mock(ReconciliationDispatcher.class); + private final EventSourceManager eventSourceManagerMock = mock(EventSourceManager.class); + private final TimerEventSource retryTimerEventSourceMock = mock(TimerEventSource.class); + private final ControllerEventSource controllerEventSourceMock = mock(ControllerEventSource.class); + private final Metrics metricsMock = mock(Metrics.class); + private EventProcessor eventProcessor; + private EventProcessor eventProcessorWithRetry; + private final RateLimiter rateLimiterMock = mock(RateLimiter.class); + + @BeforeEach + void setup() { + when(eventSourceManagerMock.getControllerEventSource()).thenReturn(controllerEventSourceMock); + eventProcessor = + spy( + new EventProcessor( + controllerConfiguration(null, rateLimiterMock), + reconciliationDispatcherMock, + eventSourceManagerMock, + null)); + eventProcessor.start(); + eventProcessorWithRetry = + spy( + new EventProcessor( + controllerConfiguration( + GenericRetry.defaultLimitedExponentialRetry(), rateLimiterMock), + reconciliationDispatcherMock, + eventSourceManagerMock, + null)); + eventProcessorWithRetry.start(); + when(eventProcessor.retryEventSource()).thenReturn(retryTimerEventSourceMock); + when(eventProcessorWithRetry.retryEventSource()).thenReturn(retryTimerEventSourceMock); + when(rateLimiterMock.isLimited(any())).thenReturn(Optional.empty()); + } + + @Test + void dispatchesEventsIfNoExecutionInProgress() { + eventProcessor.handleEvent(prepareCREvent()); + + verify(reconciliationDispatcherMock, timeout(50).times(1)).handleExecution(any()); + } + + @Test + void skipProcessingIfLatestCustomResourceNotInCache() { + Event event = prepareCREvent(); + when(controllerEventSourceMock.get(event.getRelatedCustomResourceID())) + .thenReturn(Optional.empty()); + + eventProcessor.handleEvent(event); + + verify(reconciliationDispatcherMock, timeout(50).times(0)).handleExecution(any()); + } + + @Test + void ifExecutionInProgressWaitsUntilItsFinished() { + ResourceID resourceUid = eventAlreadyUnderProcessing(); + + eventProcessor.handleEvent(nonCREvent(resourceUid)); + + verify(reconciliationDispatcherMock, timeout(SEPARATE_EXECUTION_TIMEOUT).times(1)) + .handleExecution(any()); + } + + @Test + void schedulesAnEventRetryOnException() { + TestCustomResource customResource = testCustomResource(); + + ExecutionScope executionScope = new ExecutionScope(null); + executionScope.setResource(customResource); + PostExecutionControl postExecutionControl = + PostExecutionControl.exceptionDuringExecution(new RuntimeException("test")); + + eventProcessorWithRetry.eventProcessingFinished(executionScope, postExecutionControl); + + verify(retryTimerEventSourceMock, times(1)) + .scheduleOnce( + eq(ResourceID.fromResource(customResource)), eq(GradualRetry.DEFAULT_INITIAL_INTERVAL)); + } + + @Test + void executesTheControllerInstantlyAfterErrorIfNewEventsReceived() { + Event event = prepareCREvent(); + TestCustomResource customResource = testCustomResource(); + overrideData(event.getRelatedCustomResourceID(), customResource); + PostExecutionControl postExecutionControl = + PostExecutionControl.exceptionDuringExecution(new RuntimeException("test")); + + when(reconciliationDispatcherMock.handleExecution(any())) + .thenAnswer( + (Answer) + invocationOnMock -> { + // avoid to process the first event before the second submitted + Thread.sleep(50); + return postExecutionControl; + }) + .thenReturn(PostExecutionControl.defaultDispatch()); + + // start processing an event + eventProcessorWithRetry.handleEvent(event); + // handle another event + eventProcessorWithRetry.handleEvent(event); + + ArgumentCaptor executionScopeArgumentCaptor = + ArgumentCaptor.forClass(ExecutionScope.class); + verify(reconciliationDispatcherMock, timeout(SEPARATE_EXECUTION_TIMEOUT).times(2)) + .handleExecution(executionScopeArgumentCaptor.capture()); + List allValues = executionScopeArgumentCaptor.getAllValues(); + assertThat(allValues).hasSize(2); + verify(retryTimerEventSourceMock, never()) + .scheduleOnce( + eq(ResourceID.fromResource(customResource)), eq(GradualRetry.DEFAULT_INITIAL_INTERVAL)); + } + + @Test + void successfulExecutionResetsTheRetry() { + log.info("Starting successfulExecutionResetsTheRetry"); + + Event event = prepareCREvent(); + TestCustomResource customResource = testCustomResource(); + overrideData(event.getRelatedCustomResourceID(), customResource); + PostExecutionControl postExecutionControlWithException = + PostExecutionControl.exceptionDuringExecution(new RuntimeException("test")); + PostExecutionControl defaultDispatchControl = PostExecutionControl.defaultDispatch(); + + when(reconciliationDispatcherMock.handleExecution(any())) + .thenReturn(postExecutionControlWithException) + .thenReturn(defaultDispatchControl); + + ArgumentCaptor executionScopeArgumentCaptor = + ArgumentCaptor.forClass(ExecutionScope.class); + + eventProcessorWithRetry.handleEvent(event); + verify(reconciliationDispatcherMock, timeout(SEPARATE_EXECUTION_TIMEOUT).times(1)) + .handleExecution(any()); + waitUntilProcessingFinished(eventProcessorWithRetry, event.getRelatedCustomResourceID()); + + eventProcessorWithRetry.handleEvent(event); + verify(reconciliationDispatcherMock, timeout(SEPARATE_EXECUTION_TIMEOUT).times(2)) + .handleExecution(any()); + waitUntilProcessingFinished(eventProcessorWithRetry, event.getRelatedCustomResourceID()); + + eventProcessorWithRetry.handleEvent(event); + verify(reconciliationDispatcherMock, timeout(SEPARATE_EXECUTION_TIMEOUT).times(3)) + .handleExecution(executionScopeArgumentCaptor.capture()); + waitUntilProcessingFinished(eventProcessorWithRetry, event.getRelatedCustomResourceID()); + log.info("Finished successfulExecutionResetsTheRetry"); + + List executionScopes = executionScopeArgumentCaptor.getAllValues(); + + assertThat(executionScopes).hasSize(3); + assertThat(executionScopes.get(0).getRetryInfo()).isNull(); + assertThat(executionScopes.get(2).getRetryInfo()).isNull(); + assertThat(executionScopes.get(1).getRetryInfo().getAttemptCount()).isEqualTo(1); + assertThat(executionScopes.get(1).getRetryInfo().isLastAttempt()).isEqualTo(false); + } + + private void waitUntilProcessingFinished( + EventProcessor eventProcessor, ResourceID relatedCustomResourceID) { + await() + .atMost(Duration.ofSeconds(3)) + .until(() -> !eventProcessor.isUnderProcessing(relatedCustomResourceID)); + } + + @Test + void scheduleTimedEventIfInstructedByPostExecutionControl() { + var testDelay = 10000L; + when(reconciliationDispatcherMock.handleExecution(any())) + .thenReturn(PostExecutionControl.defaultDispatch().withReSchedule(testDelay)); + + eventProcessor.handleEvent(prepareCREvent()); + + verify(retryTimerEventSourceMock, timeout(SEPARATE_EXECUTION_TIMEOUT).times(1)) + .scheduleOnce((ResourceID) any(), eq(testDelay)); + } + + @Test + void reScheduleOnlyIfNotExecutedEventsReceivedMeanwhile() throws InterruptedException { + var testDelay = 10000L; + doAnswer( + new AnswersWithDelay( + FAKE_CONTROLLER_EXECUTION_DURATION, + new Returns(PostExecutionControl.defaultDispatch().withReSchedule(testDelay)))) + .when(reconciliationDispatcherMock) + .handleExecution(any()); + var resourceId = new ResourceID("test1", "default"); + eventProcessor.handleEvent(prepareCREvent(resourceId)); + Thread.sleep(FAKE_CONTROLLER_EXECUTION_DURATION / 3); + eventProcessor.handleEvent(prepareCREvent(resourceId)); + + verify( + retryTimerEventSourceMock, + after((long) (FAKE_CONTROLLER_EXECUTION_DURATION * 1.5)).times(0)) + .scheduleOnce((ResourceID) any(), eq(testDelay)); + } + + @Test + void doNotFireEventsIfClosing() { + eventProcessor.stop(); + eventProcessor.handleEvent(prepareCREvent()); + + verify(reconciliationDispatcherMock, after(50).times(0)).handleExecution(any()); + } + + @Test + void cancelScheduleOnceEventsOnSuccessfulExecution() { + var crID = new ResourceID("test-cr", TEST_NAMESPACE); + var cr = testCustomResource(crID); + + eventProcessor.eventProcessingFinished( + new ExecutionScope(null).setResource(cr), PostExecutionControl.defaultDispatch()); + + verify(retryTimerEventSourceMock, times(1)).cancelOnceSchedule(eq(crID)); + } + + @Test + void skipsGenericEventIfNoResourceEventReceivedBefore() { + var crID = new ResourceID("test-cr", TEST_NAMESPACE); + eventProcessor = + spy( + new EventProcessor( + controllerConfiguration(null, LinearRateLimiter.deactivatedRateLimiter()), + reconciliationDispatcherMock, + eventSourceManagerMock, + metricsMock)); + + verify(reconciliationDispatcherMock, timeout(100).times(0)).handleExecution(any()); + + eventProcessor.start(); + eventProcessor.handleEvent(new Event(crID)); + + await() + .pollDelay(Duration.ofMillis(100)) + .untilAsserted( + () -> { + verify(reconciliationDispatcherMock, never()).handleExecution(any()); + }); + } + + @Test + void startProcessedMarkedEventReceivedBefore() { + var crID = new ResourceID("test-cr", TEST_NAMESPACE); + eventProcessor = + spy( + new EventProcessor( + controllerConfiguration(null, LinearRateLimiter.deactivatedRateLimiter()), + reconciliationDispatcherMock, + eventSourceManagerMock, + metricsMock)); + when(controllerEventSourceMock.get(eq(crID))).thenReturn(Optional.of(testCustomResource())); + eventProcessor.handleEvent(new ResourceEvent(ResourceAction.ADDED, crID, testCustomResource())); + + verify(reconciliationDispatcherMock, timeout(100).times(0)).handleExecution(any()); + + eventProcessor.start(); + + verify(reconciliationDispatcherMock, timeout(100).times(1)).handleExecution(any()); + verify(metricsMock, times(1)).reconcileCustomResource(any(HasMetadata.class), isNull(), any()); + } + + @Test + void notUpdatesEventSourceHandlerIfResourceUpdated() { + TestCustomResource customResource = testCustomResource(); + ExecutionScope executionScope = new ExecutionScope(null).setResource(customResource); + PostExecutionControl postExecutionControl = + PostExecutionControl.customResourceStatusPatched(customResource); + + eventProcessorWithRetry.eventProcessingFinished(executionScope, postExecutionControl); + + verify(controllerEventSourceMock, times(0)).handleRecentResourceUpdate(any(), any(), any()); + } + + @Test + void notReschedulesAfterTheFinalizerRemoveProcessed() { + TestCustomResource customResource = testCustomResource(); + markForDeletion(customResource); + ExecutionScope executionScope = new ExecutionScope(null).setResource(customResource); + PostExecutionControl postExecutionControl = + PostExecutionControl.customResourceFinalizerRemoved(customResource); + + eventProcessorWithRetry.eventProcessingFinished(executionScope, postExecutionControl); + + verify(reconciliationDispatcherMock, timeout(50).times(0)).handleExecution(any()); + } + + @Test + void skipEventProcessingIfFinalizerRemoveProcessed() { + TestCustomResource customResource = testCustomResource(); + markForDeletion(customResource); + ExecutionScope executionScope = new ExecutionScope(null).setResource(customResource); + PostExecutionControl postExecutionControl = + PostExecutionControl.customResourceFinalizerRemoved(customResource); + + eventProcessorWithRetry.eventProcessingFinished(executionScope, postExecutionControl); + eventProcessorWithRetry.handleEvent(prepareCREvent(customResource)); + + verify(reconciliationDispatcherMock, timeout(50).times(0)).handleExecution(any()); + } + + /** + * Cover corner case when a delete event missed and a new resource with same ResourceID is created + */ + @Test + void newResourceAfterMissedDeleteEvent() { + TestCustomResource customResource = testCustomResource(); + markForDeletion(customResource); + ExecutionScope executionScope = new ExecutionScope(null).setResource(customResource); + PostExecutionControl postExecutionControl = + PostExecutionControl.customResourceFinalizerRemoved(customResource); + var newResource = testCustomResource(); + newResource.getMetadata().setName(customResource.getMetadata().getName()); + + eventProcessorWithRetry.eventProcessingFinished(executionScope, postExecutionControl); + eventProcessorWithRetry.handleEvent(prepareCREvent(newResource)); + + verify(reconciliationDispatcherMock, timeout(50).times(1)).handleExecution(any()); + } + + @Test + void rateLimitsReconciliationSubmission() { + // the refresh defaultPollingPeriod value does not matter here + var refreshPeriod = Duration.ofMillis(100); + var event = prepareCREvent(); + + final var rateLimit = new RateLimitState() {}; + when(rateLimiterMock.initState()).thenReturn(rateLimit); + when(rateLimiterMock.isLimited(rateLimit)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(refreshPeriod)); + + eventProcessor.handleEvent(event); + verify(reconciliationDispatcherMock, after(FAKE_CONTROLLER_EXECUTION_DURATION).times(1)) + .handleExecution(any()); + verify(retryTimerEventSourceMock, times(0)).scheduleOnce((ResourceID) any(), anyLong()); + + eventProcessor.handleEvent(event); + verify(retryTimerEventSourceMock, times(1)).scheduleOnce((ResourceID) any(), anyLong()); + } + + @Test + void schedulesRetryForMarReconciliationInterval() { + TestCustomResource customResource = testCustomResource(); + ExecutionScope executionScope = new ExecutionScope(null).setResource(customResource); + PostExecutionControl postExecutionControl = PostExecutionControl.defaultDispatch(); + + eventProcessorWithRetry.eventProcessingFinished(executionScope, postExecutionControl); + + verify(retryTimerEventSourceMock, times(1)).scheduleOnce((ResourceID) any(), anyLong()); + } + + @Test + void schedulesRetryForMarReconciliationIntervalIfRetryExhausted() { + RetryExecution mockRetryExecution = mock(RetryExecution.class); + when(mockRetryExecution.nextDelay()).thenReturn(Optional.empty()); + Retry retry = mock(Retry.class); + when(retry.initExecution()).thenReturn(mockRetryExecution); + eventProcessorWithRetry = + spy( + new EventProcessor( + controllerConfiguration(retry, LinearRateLimiter.deactivatedRateLimiter()), + reconciliationDispatcherMock, + eventSourceManagerMock, + metricsMock)); + eventProcessorWithRetry.start(); + ExecutionScope executionScope = new ExecutionScope(null).setResource(testCustomResource()); + PostExecutionControl postExecutionControl = + PostExecutionControl.exceptionDuringExecution(new RuntimeException()); + when(eventProcessorWithRetry.retryEventSource()).thenReturn(retryTimerEventSourceMock); + + eventProcessorWithRetry.eventProcessingFinished(executionScope, postExecutionControl); + + verify(retryTimerEventSourceMock, times(1)).scheduleOnce((ResourceID) any(), anyLong()); + } + + @Test + void executionOfReconciliationShouldNotStartIfProcessorStopped() throws InterruptedException { + when(reconciliationDispatcherMock.handleExecution(any())) + .then( + (Answer) + invocationOnMock -> { + Thread.sleep(DISPATCHING_DELAY); + return PostExecutionControl.defaultDispatch(); + }); + + final var configurationService = + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), + o -> { + o.withConcurrentReconciliationThreads(1); + }); + eventProcessor = + spy( + new EventProcessor( + controllerConfiguration(null, rateLimiterMock, configurationService), + reconciliationDispatcherMock, + eventSourceManagerMock, + null)); + eventProcessor.start(); + + eventProcessor.handleEvent(prepareCREvent(new ResourceID("test1", "default"))); + eventProcessor.handleEvent(prepareCREvent(new ResourceID("test1", "default"))); + eventProcessor.stop(); + + // wait until both event should be handled + Thread.sleep(TIME_TO_WAIT_AFTER_SUBMISSION_BEFORE_EXECUTION + 2 * DISPATCHING_DELAY); + verify(reconciliationDispatcherMock, atMostOnce()).handleExecution(any()); + } + + @Test + void cleansUpForDeleteEventEvenIfProcessorNotStarted() { + ResourceID resourceID = new ResourceID("test1", "default"); + + eventProcessor = + spy( + new EventProcessor( + controllerConfiguration(null, rateLimiterMock), + reconciliationDispatcherMock, + eventSourceManagerMock, + null)); + + eventProcessor.handleEvent(prepareCREvent(resourceID)); + eventProcessor.handleEvent(new ResourceEvent(ResourceAction.DELETED, resourceID, null)); + eventProcessor.handleEvent(prepareCREvent(resourceID)); + // no exception thrown + } + + private ResourceID eventAlreadyUnderProcessing() { + when(reconciliationDispatcherMock.handleExecution(any())) + .then( + (Answer) + invocationOnMock -> { + Thread.sleep(FAKE_CONTROLLER_EXECUTION_DURATION); + return PostExecutionControl.defaultDispatch(); + }); + Event event = prepareCREvent(); + eventProcessor.handleEvent(event); + return event.getRelatedCustomResourceID(); + } + + private ResourceEvent prepareCREvent() { + return prepareCREvent(new ResourceID(UUID.randomUUID().toString(), TEST_NAMESPACE)); + } + + private ResourceEvent prepareCREvent(HasMetadata hasMetadata) { + when(controllerEventSourceMock.get(eq(ResourceID.fromResource(hasMetadata)))) + .thenReturn(Optional.of(hasMetadata)); + return new ResourceEvent( + ResourceAction.UPDATED, ResourceID.fromResource(hasMetadata), hasMetadata); + } + + private ResourceEvent prepareCREvent(ResourceID resourceID) { + TestCustomResource customResource = testCustomResource(resourceID); + when(controllerEventSourceMock.get(eq(resourceID))).thenReturn(Optional.of(customResource)); + return new ResourceEvent( + ResourceAction.UPDATED, ResourceID.fromResource(customResource), customResource); + } + + private Event nonCREvent(ResourceID relatedCustomResourceUid) { + return new Event(relatedCustomResourceUid); + } + + private void overrideData(ResourceID id, HasMetadata applyTo) { + applyTo.getMetadata().setName(id.getName()); + applyTo.getMetadata().setNamespace(id.getNamespace().orElse(null)); + } + + ControllerConfiguration controllerConfiguration(Retry retry, RateLimiter rateLimiter) { + return controllerConfiguration(retry, rateLimiter, new BaseConfigurationService()); + } + + ControllerConfiguration controllerConfiguration( + Retry retry, RateLimiter rateLimiter, ConfigurationService configurationService) { + ControllerConfiguration res = mock(ControllerConfiguration.class); + when(res.getName()).thenReturn("Test"); + when(res.getRetry()).thenReturn(retry); + when(res.getRateLimiter()).thenReturn(rateLimiter); + when(res.maxReconciliationInterval()).thenReturn(Optional.of(Duration.ofMillis(1000))); + when(res.getConfigurationService()).thenReturn(configurationService); + return res; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourceManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourceManagerTest.java new file mode 100644 index 0000000000..7592512cb3 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourceManagerTest.java @@ -0,0 +1,198 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.MockControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.event.source.AbstractEventSource; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.EventSourceStartPriority; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.timer.TimerEventSource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SuppressWarnings({"rawtypes", "unchecked"}) +class EventSourceManagerTest { + + private final EventSourceManager eventSourceManager = initManager(); + + @Test + public void registersEventSource() { + EventSource eventSource = mock(EventSource.class); + when(eventSource.resourceType()).thenReturn(EventSource.class); + when(eventSource.name()).thenReturn("name1"); + + eventSourceManager.registerEventSource(eventSource); + + final var registeredSources = eventSourceManager.getRegisteredEventSources(); + assertThat(registeredSources).contains(eventSource); + + verify(eventSource, times(1)).setEventHandler(any()); + } + + @Test + public void closeShouldCascadeToEventSources() { + EventSource eventSource = mock(EventSource.class); + when(eventSource.name()).thenReturn("name1"); + when(eventSource.resourceType()).thenReturn(EventSource.class); + + EventSource eventSource2 = mock(TimerEventSource.class); + when(eventSource2.name()).thenReturn("name2"); + when(eventSource2.resourceType()).thenReturn(AbstractEventSource.class); + + eventSourceManager.registerEventSource(eventSource); + eventSourceManager.registerEventSource(eventSource2); + + eventSourceManager.stop(); + + verify(eventSource, times(1)).stop(); + verify(eventSource2, times(1)).stop(); + } + + @Test + public void startCascadesToEventSources() { + EventSource eventSource = mock(EventSource.class); + when(eventSource.priority()).thenReturn(EventSourceStartPriority.DEFAULT); + when(eventSource.name()).thenReturn("name1"); + when(eventSource.resourceType()).thenReturn(EventSource.class); + EventSource eventSource2 = mock(TimerEventSource.class); + when(eventSource2.priority()).thenReturn(EventSourceStartPriority.DEFAULT); + when(eventSource2.name()).thenReturn("name2"); + when(eventSource2.resourceType()).thenReturn(AbstractEventSource.class); + eventSourceManager.registerEventSource(eventSource); + eventSourceManager.registerEventSource(eventSource2); + + eventSourceManager.start(); + + verify(eventSource, times(1)).start(); + verify(eventSource2, times(1)).start(); + } + + @Test + void retrievingEventSourceForClassShouldWork() { + assertThatExceptionOfType(NoEventSourceForClassException.class) + .isThrownBy(() -> eventSourceManager.getEventSourceFor(Class.class)); + + // manager is initialized with a controller configured to handle HasMetadata + EventSourceManager manager = initManager(); + assertThatExceptionOfType(NoEventSourceForClassException.class) + .isThrownBy(() -> manager.getEventSourceFor(HasMetadata.class, "unknown_name")); + + ManagedInformerEventSource eventSource = mock(ManagedInformerEventSource.class); + when(eventSource.resourceType()).thenReturn(String.class); + when(eventSource.name()).thenReturn("name1"); + manager.registerEventSource(eventSource); + + var source = manager.getEventSourceFor(String.class); + assertThat(source).isNotNull(); + assertEquals(eventSource, source); + } + + @Test + void notPossibleAddEventSourcesForSameName() { + EventSourceManager manager = initManager(); + final var name = "name1"; + + ManagedInformerEventSource eventSource = mock(ManagedInformerEventSource.class); + when(eventSource.name()).thenReturn(name); + when(eventSource.resourceType()).thenReturn(TestCustomResource.class); + manager.registerEventSource(eventSource); + + eventSource = mock(ManagedInformerEventSource.class); + when(eventSource.resourceType()).thenReturn(TestCustomResource.class); + when(eventSource.name()).thenReturn(name); + final var source = eventSource; + + final var exception = + assertThrows(OperatorException.class, () -> manager.registerEventSource(source)); + final var cause = exception.getCause(); + assertInstanceOf(IllegalArgumentException.class, cause); + assertThat(cause.getMessage()).contains("is already registered with name"); + } + + @Test + void retrievingAnEventSourceWhenMultipleAreRegisteredForATypeShouldRequireAQualifier() { + EventSourceManager manager = initManager(); + + ManagedInformerEventSource eventSource = mock(ManagedInformerEventSource.class); + when(eventSource.resourceType()).thenReturn(TestCustomResource.class); + when(eventSource.name()).thenReturn("name1"); + manager.registerEventSource(eventSource); + + ManagedInformerEventSource eventSource2 = mock(ManagedInformerEventSource.class); + when(eventSource2.name()).thenReturn("name2"); + when(eventSource2.resourceType()).thenReturn(TestCustomResource.class); + manager.registerEventSource(eventSource2); + + final var exception = + assertThrows( + IllegalArgumentException.class, + () -> manager.getEventSourceFor(TestCustomResource.class)); + assertTrue(exception.getMessage().contains("name1")); + assertTrue(exception.getMessage().contains("name2")); + + assertEquals(manager.getEventSourceFor(TestCustomResource.class, "name2"), eventSource2); + assertEquals(manager.getEventSourceFor(TestCustomResource.class, "name1"), eventSource); + } + + @Test + void changesNamespacesOnControllerAndInformerEventSources() { + String newNamespaces = "new-namespace"; + + final var configuration = MockControllerConfiguration.forResource(HasMetadata.class); + + final var configService = new BaseConfigurationService(); + when(configuration.getConfigurationService()).thenReturn(configService); + + final Controller controller = + new Controller( + mock(Reconciler.class), configuration, MockKubernetesClient.client(HasMetadata.class)); + + EventSources eventSources = spy(new EventSources()); + var controllerResourceEventSourceMock = mock(ControllerEventSource.class); + doReturn(controllerResourceEventSourceMock).when(eventSources).controllerEventSource(); + when(controllerResourceEventSourceMock.allowsNamespaceChanges()).thenCallRealMethod(); + var manager = new EventSourceManager(controller, eventSources); + + InformerEventSourceConfiguration eventSourceConfigurationMock = + mock(InformerEventSourceConfiguration.class); + InformerEventSource informerEventSource = mock(InformerEventSource.class); + when(informerEventSource.name()).thenReturn("ies"); + when(informerEventSource.resourceType()).thenReturn(TestCustomResource.class); + when(informerEventSource.configuration()).thenReturn(eventSourceConfigurationMock); + when(informerEventSource.allowsNamespaceChanges()).thenReturn(true); + manager.registerEventSource(informerEventSource); + + manager.changeNamespaces(Set.of(newNamespaces)); + + verify(informerEventSource, times(1)).changeNamespaces(Set.of(newNamespaces)); + verify(controllerResourceEventSourceMock, times(1)).changeNamespaces(Set.of(newNamespaces)); + } + + private EventSourceManager initManager() { + final var configuration = MockControllerConfiguration.forResource(ConfigMap.class); + final var configService = new BaseConfigurationService(); + when(configuration.getConfigurationService()).thenReturn(configService); + + final Controller controller = + new Controller( + mock(Reconciler.class), configuration, MockKubernetesClient.client(ConfigMap.class)); + return new EventSourceManager(controller); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourcesTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourcesTest.java new file mode 100644 index 0000000000..6e62840dd4 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventSourcesTest.java @@ -0,0 +1,227 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.util.ConcurrentModificationException; +import java.util.concurrent.Phaser; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.IntStream; + +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.Service; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.MockControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SuppressWarnings({"unchecked", "rawtypes"}) +class EventSourcesTest { + + public static final String EVENT_SOURCE_NAME = "foo"; + + @Test + void cannotAddTwoDifferentEventSourcesWithSameName() { + final var eventSources = new EventSources(); + var es1 = mock(EventSource.class); + when(es1.name()).thenReturn(EVENT_SOURCE_NAME); + when(es1.resourceType()).thenReturn(EventSource.class); + var es2 = mock(EventSource.class); + when(es2.name()).thenReturn(EVENT_SOURCE_NAME); + when(es2.resourceType()).thenReturn(EventSource.class); + + eventSources.add(es1); + assertThrows( + IllegalArgumentException.class, + () -> { + eventSources.add(es2); + }); + } + + @Test + void cannotAddTwoEventSourcesWithSame() { + final var eventSources = new EventSources(); + final var source = mock(EventSource.class); + when(source.name()).thenReturn("name"); + when(source.resourceType()).thenReturn(EventSource.class); + + eventSources.add(source); + assertThrows(IllegalArgumentException.class, () -> eventSources.add(source)); + } + + @Test + void eventSourcesStreamShouldNotReturnControllerEventSource() { + final var eventSources = new EventSources(); + final var source = mock(EventSource.class); + when(source.name()).thenReturn(EVENT_SOURCE_NAME); + when(source.resourceType()).thenReturn(EventSource.class); + + eventSources.add(source); + + assertThat(eventSources.additionalEventSources()) + .containsExactly(eventSources.retryEventSource(), source); + } + + @Test + void additionalEventSourcesShouldNotContainNamedEventSources() { + final var eventSources = new EventSources(); + final var source = mock(EventSource.class); + when(source.name()).thenReturn(EVENT_SOURCE_NAME); + when(source.resourceType()).thenReturn(EventSource.class); + eventSources.add(source); + + assertThat(eventSources.additionalEventSources()) + .containsExactly(eventSources.retryEventSource(), source); + } + + @Test + void checkControllerEventSource() { + final var eventSources = new EventSources(); + final var configuration = MockControllerConfiguration.forResource(HasMetadata.class); + when(configuration.getConfigurationService()).thenReturn(new BaseConfigurationService()); + final var controller = + new Controller( + mock(Reconciler.class), configuration, MockKubernetesClient.client(HasMetadata.class)); + eventSources.createControllerEventSource(controller); + final var controllerEventSource = eventSources.controllerEventSource(); + assertNotNull(controllerEventSource); + assertEquals(HasMetadata.class, controllerEventSource.resourceType()); + + assertEquals(controllerEventSource, eventSources.controllerEventSource()); + } + + @Test + void flatMappedSourcesShouldReturnOnlyUserRegisteredEventSources() { + final var eventSources = new EventSources(); + final var mock1 = eventSourceMockWithName(EventSource.class, "name1", HasMetadata.class); + final var mock2 = eventSourceMockWithName(EventSource.class, "name2", HasMetadata.class); + final var mock3 = eventSourceMockWithName(EventSource.class, "name3", ConfigMap.class); + + eventSources.add(mock1); + eventSources.add(mock2); + eventSources.add(mock3); + + assertThat(eventSources.flatMappedSources()).contains(mock1, mock2, mock3); + } + + @Test + void clearShouldWork() { + final var eventSources = new EventSources(); + final var mock1 = eventSourceMockWithName(EventSource.class, "name1", HasMetadata.class); + final var mock2 = eventSourceMockWithName(EventSource.class, "name2", HasMetadata.class); + final var mock3 = eventSourceMockWithName(EventSource.class, "name3", ConfigMap.class); + + eventSources.add(mock1); + eventSources.add(mock2); + eventSources.add(mock3); + + eventSources.clear(); + assertThat(eventSources.flatMappedSources()).isEmpty(); + } + + @Test + void getShouldWork() { + final var eventSources = new EventSources(); + final var mock1 = eventSourceMockWithName(EventSource.class, "name1", HasMetadata.class); + final var mock2 = eventSourceMockWithName(EventSource.class, "name2", HasMetadata.class); + final var mock3 = eventSourceMockWithName(EventSource.class, "name3", ConfigMap.class); + + eventSources.add(mock1); + eventSources.add(mock2); + eventSources.add(mock3); + + assertEquals(mock1, eventSources.get(HasMetadata.class, "name1")); + assertEquals(mock2, eventSources.get(HasMetadata.class, "name2")); + assertEquals(mock3, eventSources.get(ConfigMap.class, "name3")); + assertEquals(mock3, eventSources.get(ConfigMap.class, null)); + + assertThrows(IllegalArgumentException.class, () -> eventSources.get(HasMetadata.class, null)); + assertThrows( + IllegalArgumentException.class, () -> eventSources.get(ConfigMap.class, "unknown")); + assertThrows(IllegalArgumentException.class, () -> eventSources.get(null, null)); + assertThrows(IllegalArgumentException.class, () -> eventSources.get(HasMetadata.class, null)); + } + + @Test + void getEventSourcesShouldWork() { + final var eventSources = new EventSources(); + final var mock1 = eventSourceMockWithName(EventSource.class, "name1", HasMetadata.class); + final var mock2 = eventSourceMockWithName(EventSource.class, "name2", HasMetadata.class); + final var mock3 = eventSourceMockWithName(EventSource.class, "name3", ConfigMap.class); + + eventSources.add(mock1); + eventSources.add(mock2); + eventSources.add(mock3); + + var sources = eventSources.getEventSources(HasMetadata.class); + assertThat(sources.size()).isEqualTo(2); + assertThat(sources).contains(mock1, mock2); + + sources = eventSources.getEventSources(ConfigMap.class); + assertThat(sources.size()).isEqualTo(1); + assertThat(sources).contains(mock3); + + assertThat(eventSources.getEventSources(Service.class)).isEmpty(); + } + + @Test + void testConcurrentAddRemoveAndGet() throws InterruptedException { + + final var concurrentExceptionFound = new AtomicBoolean(false); + + for (int i = 0; i < 1000 && !concurrentExceptionFound.get(); i++) { + final var eventSources = new EventSources(); + var eventSourceList = + IntStream.range(1, 20) + .mapToObj( + n -> eventSourceMockWithName(EventSource.class, "name" + n, HasMetadata.class)) + .toList(); + + IntStream.range(1, 10).forEach(n -> eventSources.add(eventSourceList.get(n - 1))); + + var phaser = new Phaser(2); + + var t1 = + new Thread( + () -> { + phaser.arriveAndAwaitAdvance(); + IntStream.range(11, 20).forEach(n -> eventSources.add(eventSourceList.get(n - 1))); + }); + var t2 = + new Thread( + () -> { + phaser.arriveAndAwaitAdvance(); + try { + eventSources.getEventSources(eventSourceList.get(0).resourceType()); + } catch (ConcurrentModificationException e) { + concurrentExceptionFound.set(true); + } + }); + t1.start(); + t2.start(); + t1.join(); + t2.join(); + } + + assertThat(concurrentExceptionFound) + .withFailMessage("ConcurrentModificationException thrown") + .isFalse(); + } + + EventSource eventSourceMockWithName( + Class clazz, String name, Class resourceType) { + var mockedES = mock(clazz); + when(mockedES.name()).thenReturn(name); + when(mockedES.resourceType()).thenReturn(resourceType); + return mockedES; + } +} 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 new file mode 100644 index 0000000000..89f3655356 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java @@ -0,0 +1,759 @@ +package io.javaoperatorsdk.operator.processing.event; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.function.BiFunction; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +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; +import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.Cloner; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.MockControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.event.ReconciliationDispatcher.CustomResourceFacade; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; +import io.javaoperatorsdk.operator.sample.observedgeneration.ObservedGenCustomResource; +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"}) +class ReconciliationDispatcherTest { + + private static final String DEFAULT_FINALIZER = "javaoperatorsdk.io/finalizer"; + public static final String ERROR_MESSAGE = "ErrorMessage"; + public static final long RECONCILIATION_MAX_INTERVAL = 10L; + private TestCustomResource testCustomResource; + private ReconciliationDispatcher reconciliationDispatcher; + private TestReconciler reconciler; + private final CustomResourceFacade customResourceFacade = + mock(ReconciliationDispatcher.CustomResourceFacade.class); + private static ConfigurationService configurationService; + + @BeforeEach + void setup() { + initConfigService(true); + testCustomResource = TestUtils.testCustomResource(); + reconciler = spy(new TestReconciler()); + reconciliationDispatcher = + init(testCustomResource, reconciler, null, customResourceFacade, true); + } + + static void initConfigService(boolean useSSA) { + initConfigService(useSSA, true); + } + + static void initConfigService(boolean useSSA, boolean noCloning) { + /* + * We need this for mock reconcilers to properly generate the expected UpdateControl: without + * this, calls such as `when(reconciler.reconcile(eq(testCustomResource), + * any())).thenReturn(UpdateControl.updateStatus(testCustomResource))` will return null because + * equals will fail on the two equal but NOT identical TestCustomResources because equals is not + * implemented on TestCustomResourceSpec or TestCustomResourceStatus + */ + configurationService = + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), + overrider -> + overrider + .checkingCRDAndValidateLocalModel(false) + .withResourceCloner( + new Cloner() { + @Override + public R clone(R object) { + if (noCloning) { + return object; + } else { + return new KubernetesSerialization().clone(object); + } + } + }) + .withUseSSAToPatchPrimaryResource(useSSA)); + } + + private ReconciliationDispatcher init( + R customResource, + Reconciler reconciler, + ControllerConfiguration configuration, + CustomResourceFacade customResourceFacade, + boolean useFinalizer) { + + final Class resourceClass = (Class) customResource.getClass(); + configuration = + configuration == null + ? MockControllerConfiguration.forResource(resourceClass, configurationService) + : configuration; + + when(configuration.getConfigurationService()).thenReturn(configurationService); + when(configuration.getFinalizerName()).thenReturn(DEFAULT_FINALIZER); + when(configuration.getName()).thenReturn("EventDispatcherTestController"); + when(configuration.getResourceClass()).thenReturn(resourceClass); + // needed so the retry can be predefined + if (configuration.getRetry() == null) { + when(configuration.getRetry()).thenReturn(new GenericRetry()); + } + when(configuration.maxReconciliationInterval()) + .thenReturn(Optional.of(Duration.ofHours(RECONCILIATION_MAX_INTERVAL))); + + Controller controller = + new Controller<>(reconciler, configuration, MockKubernetesClient.client(resourceClass)) { + @Override + public boolean useFinalizer() { + return useFinalizer; + } + }; + controller.start(); + + return new ReconciliationDispatcher<>(controller, customResourceFacade); + } + + @Test + void addFinalizerOnNewResource() { + assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); + verify(customResourceFacade, times(1)) + .patchResourceWithSSA( + argThat(testCustomResource -> testCustomResource.hasFinalizer(DEFAULT_FINALIZER))); + } + + @Test + void addFinalizerOnNewResourceWithoutSSA() { + initConfigService(false); + final ReconciliationDispatcher dispatcher = + init(testCustomResource, reconciler, null, customResourceFacade, true); + + assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); + dispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + 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(); + } + + @Test + void callCreateOrUpdateOnNewResourceIfFinalizerSet() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + verify(reconciler, times(1)).reconcile(ArgumentMatchers.eq(testCustomResource), any()); + } + + @Test + void patchesBothResourceAndStatusIfFinalizerSet() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + + reconciler.reconcile = (r, c) -> UpdateControl.patchResourceAndStatus(testCustomResource); + when(customResourceFacade.patchResource(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()); + } + + @Test + void patchesStatus() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + + reconciler.reconcile = (r, c) -> UpdateControl.patchStatus(testCustomResource); + + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); + verify(customResourceFacade, never()).patchResource(any(), any()); + } + + @Test + void callCreateOrUpdateOnModifiedResourceIfFinalizerSet() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + verify(reconciler, times(1)).reconcile(ArgumentMatchers.eq(testCustomResource), any()); + } + + @Test + void callsDeleteIfObjectHasFinalizerAndMarkedForDelete() { + // we need to add the finalizer before marking it for deletion, as otherwise it won't get added + assertTrue(testCustomResource.addFinalizer(DEFAULT_FINALIZER)); + markForDeletion(testCustomResource); + + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + verify(reconciler, times(1)).cleanup(eq(testCustomResource), any()); + } + + @Test + void removesDefaultFinalizerOnDeleteIfSet() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + markForDeletion(testCustomResource); + + var postExecControl = + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + 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()); + } + + @Test + void doesNotCallDeleteOnControllerIfMarkedForDeletionWhenNoFinalizerIsConfigured() { + final ReconciliationDispatcher dispatcher = + init(testCustomResource, reconciler, null, customResourceFacade, false); + markForDeletion(testCustomResource); + + dispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + verify(reconciler, times(0)).cleanup(eq(testCustomResource), any()); + } + + @Test + void doNotCallDeleteIfMarkedForDeletionWhenFinalizerHasAlreadyBeenRemoved() { + markForDeletion(testCustomResource); + + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + verify(reconciler, never()).cleanup(eq(testCustomResource), any()); + } + + @Test + void doesNotAddFinalizerIfConfiguredNotTo() { + final ReconciliationDispatcher dispatcher = + init(testCustomResource, reconciler, null, customResourceFacade, false); + + dispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + assertEquals(0, testCustomResource.getMetadata().getFinalizers().size()); + } + + @Test + void doesNotRemovesTheSetFinalizerIfTheDeleteNotMethodInstructsIt() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + + reconciler.cleanup = (r, c) -> DeleteControl.noFinalizerRemoval(); + markForDeletion(testCustomResource); + + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + assertEquals(1, testCustomResource.getMetadata().getFinalizers().size()); + verify(customResourceFacade, never()).patchResource(any(), any()); + } + + @Test + void doesNotUpdateTheResourceIfNoUpdateUpdateControlIfFinalizerSet() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + + reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); + + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + verify(customResourceFacade, never()).patchResource(any(), any()); + verify(customResourceFacade, never()).patchStatus(eq(testCustomResource), any()); + } + + @Test + void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() { + removeFinalizers(testCustomResource); + reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); + when(customResourceFacade.patchResourceWithSSA(any())).thenReturn(testCustomResource); + + var postExecControl = + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + verify(customResourceFacade, times(1)) + .patchResourceWithSSA(argThat(a -> !a.getMetadata().getFinalizers().isEmpty())); + assertThat(postExecControl.updateIsStatusPatch()).isFalse(); + assertThat(postExecControl.getUpdatedCustomResource()).isPresent(); + } + + @Test + void doesNotCallDeleteIfMarkedForDeletionButNotOurFinalizer() { + removeFinalizers(testCustomResource); + markForDeletion(testCustomResource); + + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + verify(customResourceFacade, never()).patchResource(any(), any()); + verify(reconciler, never()).cleanup(eq(testCustomResource), any()); + } + + @Test + void executeControllerRegardlessGenerationInNonGenerationAwareModeIfFinalizerSet() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + verify(reconciler, times(2)).reconcile(eq(testCustomResource), any()); + } + + @Test + void propagatesRetryInfoToContextIfFinalizerSet() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + + reconciliationDispatcher.handleExecution( + new ExecutionScope( + new RetryInfo() { + @Override + public int getAttemptCount() { + return 2; + } + + @Override + public boolean isLastAttempt() { + return true; + } + }) + .setResource(testCustomResource)); + + ArgumentCaptor contextArgumentCaptor = ArgumentCaptor.forClass(Context.class); + verify(reconciler, times(1)).reconcile(any(), contextArgumentCaptor.capture()); + Context context = contextArgumentCaptor.getValue(); + final var retryInfo = context.getRetryInfo().orElseGet(() -> fail("Missing optional")); + assertThat(retryInfo.getAttemptCount()).isEqualTo(2); + assertThat(retryInfo.isLastAttempt()).isEqualTo(true); + } + + @Test + void setReScheduleToPostExecutionControlFromUpdateControl() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + + reconciler.reconcile = + (r, c) -> UpdateControl.patchStatus(testCustomResource).rescheduleAfter(1000L); + + PostExecutionControl control = + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + assertThat(control.getReScheduleDelay().orElseGet(() -> fail("Missing optional"))) + .isEqualTo(1000L); + } + + @Test + void reScheduleOnDeleteWithoutFinalizerRemoval() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + markForDeletion(testCustomResource); + + reconciler.cleanup = + (r, c) -> DeleteControl.noFinalizerRemoval().rescheduleAfter(1, TimeUnit.SECONDS); + + PostExecutionControl control = + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + assertThat(control.getReScheduleDelay().orElseGet(() -> fail("Missing optional"))) + .isEqualTo(1000L); + } + + @Test + void doesNotUpdatesObservedGenerationIfStatusIsNotPatchedWhenUsingSSA() throws Exception { + var observedGenResource = createObservedGenCustomResource(); + + Reconciler reconciler = mock(Reconciler.class); + final var config = MockControllerConfiguration.forResource(ObservedGenCustomResource.class); + 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); + var dispatcher = init(observedGenResource, reconciler, config, facade, true); + + PostExecutionControl control = + dispatcher.handleExecution(executionScopeWithCREvent(observedGenResource)); + assertThat(control.getUpdatedCustomResource()).isEmpty(); + } + + @Test + void doesNotPatchObservedGenerationOnCustomResourcePatch() throws Exception { + var observedGenResource = createObservedGenCustomResource(); + + Reconciler reconciler = mock(Reconciler.class); + final var config = MockControllerConfiguration.forResource(ObservedGenCustomResource.class); + CustomResourceFacade facade = mock(CustomResourceFacade.class); + when(config.isGenerationAware()).thenReturn(true); + when(reconciler.reconcile(any(), any())) + .thenReturn(UpdateControl.patchResource(observedGenResource)); + when(facade.patchResource(any(), any())).thenReturn(observedGenResource); + var dispatcher = init(observedGenResource, reconciler, config, facade, false); + + dispatcher.handleExecution(executionScopeWithCREvent(observedGenResource)); + + verify(facade, never()).patchStatus(any(), any()); + } + + @Test + void callErrorStatusHandlerIfImplemented() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + + reconciler.reconcile = + (r, c) -> { + throw new IllegalStateException("Error Status Test"); + }; + reconciler.errorHandler = + () -> { + testCustomResource.getStatus().setConfigMapStatus(ERROR_MESSAGE); + return ErrorStatusUpdateControl.patchStatus(testCustomResource); + }; + + reconciliationDispatcher.handleExecution( + new ExecutionScope( + new RetryInfo() { + @Override + public int getAttemptCount() { + return 2; + } + + @Override + public boolean isLastAttempt() { + return true; + } + }) + .setResource(testCustomResource)); + + verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); + verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any()); + } + + @Test + void callErrorStatusHandlerEvenOnFirstError() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + + reconciler.reconcile = + (r, c) -> { + throw new IllegalStateException("Error Status Test"); + }; + reconciler.errorHandler = + () -> { + testCustomResource.getStatus().setConfigMapStatus(ERROR_MESSAGE); + return ErrorStatusUpdateControl.patchStatus(testCustomResource); + }; + + var postExecControl = + reconciliationDispatcher.handleExecution( + new ExecutionScope(null).setResource(testCustomResource)); + verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); + verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any()); + assertThat(postExecControl.exceptionDuringExecution()).isTrue(); + } + + @Test + void errorHandlerCanInstructNoRetryWithUpdate() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + reconciler.reconcile = + (r, c) -> { + throw new IllegalStateException("Error Status Test"); + }; + reconciler.errorHandler = + () -> { + testCustomResource.getStatus().setConfigMapStatus(ERROR_MESSAGE); + return ErrorStatusUpdateControl.patchStatus(testCustomResource).withNoRetry(); + }; + + var postExecControl = + reconciliationDispatcher.handleExecution( + new ExecutionScope(null).setResource(testCustomResource)); + + verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any()); + verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); + assertThat(postExecControl.exceptionDuringExecution()).isFalse(); + } + + @Test + void errorHandlerCanInstructNoRetryNoUpdate() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + reconciler.reconcile = + (r, c) -> { + throw new IllegalStateException("Error Status Test"); + }; + reconciler.errorHandler = + () -> { + testCustomResource.getStatus().setConfigMapStatus(ERROR_MESSAGE); + return ErrorStatusUpdateControl.noStatusUpdate().withNoRetry(); + }; + + var postExecControl = + reconciliationDispatcher.handleExecution( + new ExecutionScope(null).setResource(testCustomResource)); + + verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any()); + verify(customResourceFacade, times(0)).patchStatus(eq(testCustomResource), any()); + assertThat(postExecControl.exceptionDuringExecution()).isFalse(); + } + + @Test + void errorStatusHandlerCanPatchResource() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + reconciler.reconcile = + (r, c) -> { + throw new IllegalStateException("Error Status Test"); + }; + reconciler.errorHandler = () -> ErrorStatusUpdateControl.patchStatus(testCustomResource); + + reconciliationDispatcher.handleExecution( + new ExecutionScope(null).setResource(testCustomResource)); + + verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); + verify(reconciler, times(1)).updateErrorStatus(eq(testCustomResource), any(), any()); + } + + @Test + void ifRetryLimitedToZeroMaxAttemptsErrorHandlerGetsCorrectLastAttempt() { + var configuration = + MockControllerConfiguration.forResource( + (Class) testCustomResource.getClass()); + when(configuration.getRetry()).thenReturn(new GenericRetry().setMaxAttempts(0)); + reconciliationDispatcher = + init(testCustomResource, reconciler, configuration, customResourceFacade, false); + + reconciler.reconcile = + (r, c) -> { + throw new IllegalStateException("Error Status Test"); + }; + + reconciler.errorHandler = () -> ErrorStatusUpdateControl.noStatusUpdate(); + + reconciliationDispatcher.handleExecution( + new ExecutionScope(null).setResource(testCustomResource)); + + verify(reconciler, times(1)) + .updateErrorStatus( + any(), + ArgumentMatchers.argThat( + context -> { + var retryInfo = context.getRetryInfo().orElseThrow(); + return retryInfo.isLastAttempt(); + }), + any()); + } + + @Test + void canSkipSchedulingMaxDelayIf() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + + reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); + when(reconciliationDispatcher.configuration().maxReconciliationInterval()) + .thenReturn(Optional.empty()); + + PostExecutionControl control = + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + 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; + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + reconciler.reconcile = + (r, c) -> { + throw new IllegalStateException("Error Status Test"); + }; + reconciler.errorHandler = + () -> ErrorStatusUpdateControl.noStatusUpdate().rescheduleAfter(delay); + + var res = + reconciliationDispatcher.handleExecution( + new ExecutionScope(null).setResource(testCustomResource)); + + assertThat(res.getReScheduleDelay()).contains(delay); + assertThat(res.getRuntimeException()).isEmpty(); + } + + @Test + void reconcilerContextUsesTheSameInstanceOfResourceAsParam() { + initConfigService(false, false); + + final ReconciliationDispatcher dispatcher = + init(testCustomResource, reconciler, null, customResourceFacade, true); + + testCustomResource.addFinalizer(DEFAULT_FINALIZER); + ArgumentCaptor contextArgumentCaptor = + ArgumentCaptor.forClass(DefaultContext.class); + ArgumentCaptor customResourceCaptor = + ArgumentCaptor.forClass(TestCustomResource.class); + + dispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + verify(reconciler, times(1)) + .reconcile(customResourceCaptor.capture(), contextArgumentCaptor.capture()); + + assertThat(contextArgumentCaptor.getValue().getPrimaryResource()) + .isSameAs(customResourceCaptor.getValue()) + .isNotSameAs(testCustomResource); + } + + private ObservedGenCustomResource createObservedGenCustomResource() { + ObservedGenCustomResource observedGenCustomResource = new ObservedGenCustomResource(); + observedGenCustomResource.setMetadata(new ObjectMeta()); + observedGenCustomResource.getMetadata().setGeneration(1L); + observedGenCustomResource.getMetadata().setFinalizers(new ArrayList<>()); + observedGenCustomResource.getMetadata().getFinalizers().add(DEFAULT_FINALIZER); + return observedGenCustomResource; + } + + TestCustomResource createResourceWithFinalizer() { + var resourceWithFinalizer = TestUtils.testCustomResource(); + resourceWithFinalizer.addFinalizer(DEFAULT_FINALIZER); + return resourceWithFinalizer; + } + + private void removeFinalizers(CustomResource customResource) { + customResource.getMetadata().getFinalizers().clear(); + } + + public ExecutionScope executionScopeWithCREvent(T resource) { + return (ExecutionScope) new ExecutionScope<>(null).setResource(resource); + } + + private class TestReconciler + implements Reconciler, Cleaner { + + private BiFunction> reconcile; + private BiFunction cleanup; + private Supplier errorHandler; + + @Override + public UpdateControl reconcile( + TestCustomResource resource, Context context) { + if (reconcile != null && resource.equals(testCustomResource)) { + return reconcile.apply(resource, context); + } + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup(TestCustomResource resource, Context context) { + if (cleanup != null && resource.equals(testCustomResource)) { + return cleanup.apply(resource, context); + } + return DeleteControl.defaultDelete(); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + TestCustomResource resource, Context context, Exception e) { + return errorHandler != null ? errorHandler.get() : ErrorStatusUpdateControl.noStatusUpdate(); + } + } +} 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 new file mode 100644 index 0000000000..487ba25885 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ResourceStateManagerTest.java @@ -0,0 +1,116 @@ +package io.javaoperatorsdk.operator.processing.event; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +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.controller.ResourceEvent; + +import static org.assertj.core.api.Assertions.assertThat; + +class ResourceStateManagerTest { + + private final ResourceStateManager manager = new ResourceStateManager(); + private final ResourceID sampleResourceID = new ResourceID("test-name"); + private final ResourceID sampleResourceID2 = new ResourceID("test-name2"); + private ResourceState state; + private ResourceState state2; + + @BeforeEach + void init() { + manager.remove(sampleResourceID); + manager.remove(sampleResourceID2); + + state = manager.getOrCreate(sampleResourceID); + state2 = manager.getOrCreate(sampleResourceID2); + } + + @Test + public void returnsNoEventPresentIfNotMarkedYet() { + assertThat(state.noEventPresent()).isTrue(); + } + + @Test + public void marksEvent() { + state.markEventReceived(); + + assertThat(state.eventPresent()).isTrue(); + assertThat(state.deleteEventPresent()).isFalse(); + } + + @Test + public void marksDeleteEvent() { + state.markDeleteEventReceived(); + + assertThat(state.deleteEventPresent()).isTrue(); + assertThat(state.eventPresent()).isFalse(); + } + + @Test + public void afterDeleteEventMarkEventIsNotRelevant() { + state.markEventReceived(); + + state.markDeleteEventReceived(); + + assertThat(state.deleteEventPresent()).isTrue(); + assertThat(state.eventPresent()).isFalse(); + } + + @Test + public void cleansUp() { + state.markEventReceived(); + state.markDeleteEventReceived(); + + manager.remove(sampleResourceID); + + state = manager.getOrCreate(sampleResourceID); + assertThat(state.deleteEventPresent()).isFalse(); + assertThat(state.eventPresent()).isFalse(); + } + + @Test + public void cannotMarkEventAfterDeleteEventReceived() { + Assertions.assertThrows( + IllegalStateException.class, + () -> { + state.markDeleteEventReceived(); + state.markEventReceived(); + }); + } + + @Test + public void listsResourceIDSWithEventsPresent() { + state.markEventReceived(); + state2.markEventReceived(); + state.unMarkEventReceived(); + + var res = manager.resourcesWithEventPresent(); + + assertThat(res).hasSize(1); + assertThat(res.get(0).getId()).isEqualTo(sampleResourceID2); + } + + @Test + void createStateOnlyOnResourceEvent() { + var state = manager.getOrCreateOnResourceEvent(new Event(new ResourceID("newEvent"))); + + assertThat(state).isEmpty(); + + state = + manager.getOrCreateOnResourceEvent( + new ResourceEvent( + ResourceAction.ADDED, new ResourceID("newEvent"), TestUtils.testCustomResource())); + + assertThat(state).isNotNull(); + } + + @Test + void createsOnlyResourceEventReturnsPreviouslyCreatedState() { + manager.getOrCreate(new ResourceID("newEvent")); + + var res = manager.getOrCreateOnResourceEvent(new Event(new ResourceID("newEvent"))); + assertThat(res).isNotNull(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiterTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiterTest.java new file mode 100644 index 0000000000..ea3d619d13 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/rate/LinearRateLimiterTest.java @@ -0,0 +1,67 @@ +package io.javaoperatorsdk.operator.processing.event.rate; + +import java.time.Duration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LinearRateLimiterTest { + + public static final Duration REFRESH_PERIOD = Duration.ofMillis(300); + private RateState state; + + @BeforeEach + void initState() { + state = RateState.initialState(); + } + + @Test + void acquirePermissionForNewResource() { + var rl = new LinearRateLimiter(REFRESH_PERIOD, 2); + var res = rl.isLimited(state); + assertThat(res).isEmpty(); + res = rl.isLimited(state); + assertThat(res).isEmpty(); + + res = rl.isLimited(state); + assertThat(res).isNotEmpty(); + } + + @Test + void returnsMinimalDurationToAcquirePermission() { + var rl = new LinearRateLimiter(REFRESH_PERIOD, 1); + var res = rl.isLimited(state); + assertThat(res).isEmpty(); + + res = rl.isLimited(state); + + assertThat(res).isPresent(); + assertThat(res.get()).isLessThan(REFRESH_PERIOD); + } + + @Test + void resetsPeriodAfterLimit() throws InterruptedException { + var rl = new LinearRateLimiter(REFRESH_PERIOD, 1); + var res = rl.isLimited(state); + assertThat(res).isEmpty(); + res = rl.isLimited(state); + assertThat(res).isPresent(); + + // sleep plus some slack + Thread.sleep(REFRESH_PERIOD.toMillis() + REFRESH_PERIOD.toMillis() / 3); + + res = rl.isLimited(state); + assertThat(res).isEmpty(); + } + + @Test + void rateLimitCanBeTurnedOff() { + var rl = new LinearRateLimiter(REFRESH_PERIOD, LinearRateLimiter.NO_LIMIT_PERIOD); + + var res = rl.isLimited(state); + + assertThat(res).isEmpty(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSourceTestBase.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSourceTestBase.java new file mode 100644 index 0000000000..9cea4790b0 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/AbstractEventSourceTestBase.java @@ -0,0 +1,56 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import org.junit.jupiter.api.AfterEach; + +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.processing.event.EventHandler; +import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; + +import static org.mockito.Mockito.mock; + +public class AbstractEventSourceTestBase { + protected T eventHandler; + protected S source; + + @AfterEach + public void tearDown() { + source.stop(); + } + + public void setUpSource(S source) { + setUpSource(source, true); + } + + public void setUpSource(S source, boolean start, ControllerConfiguration configurationService) { + setUpSource(source, (T) mock(EventHandler.class), start, configurationService); + } + + @SuppressWarnings("unchecked") + public void setUpSource(S source, boolean start) { + setUpSource(source, (T) mock(EventHandler.class), start); + } + + public void setUpSource(S source, T eventHandler) { + setUpSource(source, eventHandler, true); + } + + public void setUpSource(S source, T eventHandler, boolean start) { + setUpSource(source, eventHandler, start, mock(ControllerConfiguration.class)); + } + + public void setUpSource( + S source, T eventHandler, boolean start, ControllerConfiguration controllerConfiguration) { + this.eventHandler = eventHandler; + this.source = source; + + if (source instanceof ManagedInformerEventSource) { + ((ManagedInformerEventSource) source).setControllerConfiguration(controllerConfiguration); + } + + source.setEventHandler(eventHandler); + + if (start) { + source.start(); + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java new file mode 100644 index 0000000000..675935d003 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ExternalResourceCachingEventSourceTest.java @@ -0,0 +1,204 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.EventHandler; + +import static io.javaoperatorsdk.operator.processing.event.source.SampleExternalResource.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class ExternalResourceCachingEventSourceTest + extends AbstractEventSourceTestBase< + ExternalResourceCachingEventSource, EventHandler> { + + @BeforeEach + public void setup() { + setUpSource(new TestExternalCachingEventSource()); + } + + @Test + void putsNewResourceIntoCacheAndProducesEvent() { + source.handleResources(primaryID1(), testResource1()); + + verify(eventHandler, times(1)).handleEvent(new Event(primaryID1())); + assertThat(source.getSecondaryResource(primaryID1())).isPresent(); + } + + @Test + void propagatesEventIfResourceChanged() { + var res2 = testResource1(); + res2.setValue("changedValue"); + source.handleResources(primaryID1(), testResource1()); + source.handleResources(primaryID1(), res2); + + verify(eventHandler, times(2)).handleEvent(new Event(primaryID1())); + assertThat(source.getSecondaryResource(primaryID1())).contains(res2); + } + + @Test + void noEventPropagatedIfTheResourceIsNotChanged() { + source.handleResources(primaryID1(), testResource1()); + source.handleResources(primaryID1(), testResource1()); + + verify(eventHandler, times(1)).handleEvent(new Event(primaryID1())); + assertThat(source.getSecondaryResource(primaryID1())).isPresent(); + } + + @Test + void propagatesEventOnDeleteIfThereIsPrevResourceInCache() { + source.handleResources(primaryID1(), testResource1()); + source.handleDelete(primaryID1()); + + verify(eventHandler, times(2)).handleEvent(new Event(primaryID1())); + assertThat(source.getSecondaryResource(primaryID1())).isNotPresent(); + } + + @Test + void noEventOnDeleteIfResourceWasNotInCacheBefore() { + source.handleDelete(primaryID1()); + + verify(eventHandler, times(0)).handleEvent(new Event(primaryID1())); + } + + @Test + void handleMultipleResourceTrivialCase() { + source.handleResources(primaryID1(), Set.of(testResource1(), testResource2())); + + verify(eventHandler, times(1)).handleEvent(new Event(primaryID1())); + assertThat(source.getSecondaryResources(primaryID1())) + .containsExactlyInAnyOrder(testResource1(), testResource2()); + } + + @Test + void handleOneResourceRemovedFromMultiple() { + source.handleResources(primaryID1(), Set.of(testResource1(), testResource2())); + source.handleResources(primaryID1(), Set.of(testResource1())); + + verify(eventHandler, times(2)).handleEvent(new Event(primaryID1())); + assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource1()); + } + + @Test + void addingAdditionalResource() { + source.handleResources(primaryID1(), Set.of(testResource1())); + source.handleResources(primaryID1(), Set.of(testResource1(), testResource2())); + + verify(eventHandler, times(2)).handleEvent(new Event(primaryID1())); + assertThat(source.getSecondaryResources(primaryID1())) + .containsExactlyInAnyOrder(testResource1(), testResource2()); + } + + @Test + void replacingResource() { + source.handleResources(primaryID1(), Set.of(testResource1())); + source.handleResources(primaryID1(), Set.of(testResource2())); + + verify(eventHandler, times(2)).handleEvent(new Event(primaryID1())); + assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource2()); + } + + @Test + void handlesDeleteFromMultipleResources() { + source.handleResources(primaryID1(), Set.of(testResource1(), testResource2())); + source.handleDelete(primaryID1(), testResource1()); + + verify(eventHandler, times(2)).handleEvent(new Event(primaryID1())); + assertThat(source.getSecondaryResources(primaryID1())).containsExactly(testResource2()); + } + + @Test + void handlesDeleteAllFromMultipleResources() { + source.handleResources(primaryID1(), Set.of(testResource1(), testResource2())); + source.handleDeletes(primaryID1(), Set.of(testResource1(), testResource2())); + + verify(eventHandler, times(2)).handleEvent(new Event(primaryID1())); + assertThat(source.getSecondaryResources(primaryID1())).isEmpty(); + } + + @Test + void canFilterOnDeleteEvents() { + TestExternalCachingEventSource delFilteringEventSource = new TestExternalCachingEventSource(); + delFilteringEventSource.setOnDeleteFilter((res, b) -> false); + setUpSource(delFilteringEventSource); + // try without any resources added + source.handleDeletes(primaryID1(), Set.of(testResource1(), testResource2())); + source.handleResources(primaryID1(), Set.of(testResource1(), testResource2())); + // handling the add event + verify(eventHandler, times(1)).handleEvent(any()); + + source.handleDeletes(primaryID1(), Set.of(testResource1(), testResource2())); + + // no more invocation + verify(eventHandler, times(1)).handleEvent(any()); + } + + @Test + void filtersAddEvents() { + TestExternalCachingEventSource delFilteringEventSource = new TestExternalCachingEventSource(); + delFilteringEventSource.setOnAddFilter((res) -> false); + setUpSource(delFilteringEventSource); + + source.handleResources(primaryID1(), Set.of(testResource1())); + verify(eventHandler, times(0)).handleEvent(any()); + + source.handleResources(primaryID1(), Set.of(testResource1(), testResource2())); + verify(eventHandler, times(0)).handleEvent(any()); + } + + @Test + void filtersUpdateEvents() { + TestExternalCachingEventSource delFilteringEventSource = new TestExternalCachingEventSource(); + delFilteringEventSource.setOnUpdateFilter((res, res2) -> false); + setUpSource(delFilteringEventSource); + source.handleResources(primaryID1(), Set.of(testResource1())); + verify(eventHandler, times(1)).handleEvent(any()); + + var resource = testResource1(); + resource.setValue("changed value"); + source.handleResources(primaryID1(), Set.of(resource)); + + verify(eventHandler, times(1)).handleEvent(any()); + } + + @Test + void filtersImplicitDeleteEvents() { + TestExternalCachingEventSource delFilteringEventSource = new TestExternalCachingEventSource(); + delFilteringEventSource.setOnDeleteFilter((res, b) -> false); + setUpSource(delFilteringEventSource); + + source.handleResources(primaryID1(), Set.of(testResource1(), testResource2())); + verify(eventHandler, times(1)).handleEvent(any()); + + source.handleResources(primaryID1(), Set.of(testResource1())); + verify(eventHandler, times(1)).handleEvent(any()); + } + + @Test + void genericFilteringEvents() { + TestExternalCachingEventSource delFilteringEventSource = new TestExternalCachingEventSource(); + delFilteringEventSource.setGenericFilter(res -> false); + setUpSource(delFilteringEventSource); + + source.handleResources(primaryID1(), Set.of(testResource1())); + verify(eventHandler, times(0)).handleEvent(any()); + + source.handleResources(primaryID1(), Set.of(testResource1(), testResource2())); + verify(eventHandler, times(0)).handleEvent(any()); + + source.handleResources(primaryID1(), Set.of(testResource2())); + verify(eventHandler, times(0)).handleEvent(any()); + } + + public static class TestExternalCachingEventSource + extends ExternalResourceCachingEventSource { + public TestExternalCachingEventSource() { + super(SampleExternalResource.class, SampleExternalResource::getName); + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/SampleExternalResource.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/SampleExternalResource.java new file mode 100644 index 0000000000..86eccbc57b --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/SampleExternalResource.java @@ -0,0 +1,69 @@ +package io.javaoperatorsdk.operator.processing.event.source; + +import java.io.Serializable; +import java.util.Objects; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public class SampleExternalResource implements Serializable { + + public static final String DEFAULT_VALUE_1 = "value1"; + public static final String DEFAULT_VALUE_2 = "value2"; + public static final String NAME_1 = "name1"; + public static final String NAME_2 = "name2"; + + public static SampleExternalResource testResource1() { + return new SampleExternalResource(NAME_1, DEFAULT_VALUE_1); + } + + public static SampleExternalResource testResource2() { + return new SampleExternalResource(NAME_2, DEFAULT_VALUE_2); + } + + public static ResourceID primaryID1() { + return new ResourceID(NAME_1, "testns"); + } + + public static ResourceID primaryID2() { + return new ResourceID(NAME_2, "testns"); + } + + private String name; + private String value; + + public SampleExternalResource(String name, String value) { + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public SampleExternalResource setName(String name) { + this.name = name; + return this; + } + + public String getValue() { + return value; + } + + public SampleExternalResource setValue(String value) { + this.value = value; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SampleExternalResource that = (SampleExternalResource) o; + return Objects.equals(name, that.name) && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(name, value); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedItemStoreTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedItemStoreTest.java new file mode 100644 index 0000000000..14414ddc64 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/BoundedItemStoreTest.java @@ -0,0 +1,105 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.informers.cache.Cache; +import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static io.javaoperatorsdk.operator.processing.event.source.cache.BoundedItemStore.namespaceKeyFunc; +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.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class BoundedItemStoreTest { + + private BoundedItemStore boundedItemStore; + + @SuppressWarnings("unchecked") + private final BoundedCache boundedCache = mock(BoundedCache.class); + + @SuppressWarnings("unchecked") + private final ResourceFetcher resourceFetcher = + mock(ResourceFetcher.class); + + @BeforeEach + void setup() { + boundedItemStore = + new BoundedItemStore<>( + boundedCache, TestCustomResource.class, namespaceKeyFunc(), resourceFetcher); + } + + @Test + void shouldNotFetchResourcesFromServerIfNotKnown() { + var res = boundedItemStore.get(testRes1Key()); + + assertThat(res).isNull(); + verify(resourceFetcher, never()).fetchResource(any()); + } + + @Test + void getsResourceFromServerIfNotInCache() { + boundedItemStore.put(testRes1Key(), TestUtils.testCustomResource1()); + when(resourceFetcher.fetchResource(testRes1Key())).thenReturn(TestUtils.testCustomResource1()); + + var res = boundedItemStore.get(testRes1Key()); + + assertThat(res).isNotNull(); + verify(resourceFetcher, times(1)).fetchResource(any()); + } + + @Test + void removesResourcesNotFoundOnServerFromStore() { + boundedItemStore.put(testRes1Key(), TestUtils.testCustomResource1()); + when(resourceFetcher.fetchResource(testRes1Key())).thenReturn(null); + + var res = boundedItemStore.get(testRes1Key()); + + assertThat(res).isNull(); + assertThat(boundedItemStore.keySet()).isEmpty(); + } + + @Test + void removesResourceFromCache() { + boundedItemStore.put(testRes1Key(), TestUtils.testCustomResource1()); + + boundedItemStore.remove(testRes1Key()); + + var res = boundedItemStore.get(testRes1Key()); + verify(resourceFetcher, never()).fetchResource(any()); + assertThat(res).isNull(); + assertThat(boundedItemStore.keySet()).isEmpty(); + } + + @Test + void readingKeySetDoesNotReadFromBoundedCache() { + boundedItemStore.put(testRes1Key(), TestUtils.testCustomResource1()); + + boundedItemStore.keySet(); + + verify(boundedCache, never()).get(any()); + } + + @Test + void readingValuesDoesNotReadFromBoundedCache() { + boundedItemStore.put(testRes1Key(), TestUtils.testCustomResource1()); + + boundedItemStore.values(); + + verify(boundedCache, never()).get(any()); + } + + String key(HasMetadata r) { + return Cache.namespaceKeyFunc(r.getMetadata().getNamespace(), r.getMetadata().getName()); + } + + String testRes1Key() { + return key(TestUtils.testCustomResource1()); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/KubernetesResourceFetcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/KubernetesResourceFetcherTest.java new file mode 100644 index 0000000000..86ecb0d729 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/cache/KubernetesResourceFetcherTest.java @@ -0,0 +1,47 @@ +package io.javaoperatorsdk.operator.processing.event.source.cache; + +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.apiextensions.v1.CustomResourceDefinition; + +import static org.assertj.core.api.Assertions.assertThat; + +class KubernetesResourceFetcherTest { + + public static final String DEFAULT_NAMESPACE = "default"; + public static final String TEST_RESOURCE_NAME = "test1"; + + @Test + void inverseKeyFunction() { + String key = BoundedItemStore.namespaceKeyFunc().apply(namespacedResource()); + var resourceID = KubernetesResourceFetcher.inverseNamespaceKeyFunction().apply(key); + + assertThat(resourceID.getNamespace()).isPresent().get().isEqualTo(DEFAULT_NAMESPACE); + assertThat(resourceID.getName()).isEqualTo(TEST_RESOURCE_NAME); + + key = BoundedItemStore.namespaceKeyFunc().apply(clusterScopedResource()); + resourceID = KubernetesResourceFetcher.inverseNamespaceKeyFunction().apply(key); + + assertThat(resourceID.getNamespace()).isEmpty(); + assertThat(resourceID.getName()).isEqualTo(TEST_RESOURCE_NAME); + } + + private HasMetadata namespacedResource() { + var cm = new ConfigMap(); + cm.setMetadata( + new ObjectMetaBuilder() + .withName(TEST_RESOURCE_NAME) + .withNamespace(DEFAULT_NAMESPACE) + .build()); + return cm; + } + + private HasMetadata clusterScopedResource() { + var cm = new CustomResourceDefinition(); + cm.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + return cm; + } +} 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 new file mode 100644 index 0000000000..6548bbddc7 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java @@ -0,0 +1,214 @@ +package io.javaoperatorsdk.operator.processing.event.source.controller; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.ResolvedControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.Controller; +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.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 org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class ControllerEventSourceTest + extends AbstractEventSourceTestBase, EventHandler> { + + public static final String FINALIZER = + ReconcilerUtils.getDefaultFinalizerName(TestCustomResource.class); + + private final TestController testController = new TestController(true); + private final ControllerConfiguration controllerConfig = mock(ControllerConfiguration.class); + + @BeforeEach + public void setup() { + + when(controllerConfig.getConfigurationService()).thenReturn(new BaseConfigurationService()); + + setUpSource(new ControllerEventSource<>(testController), true, controllerConfig); + } + + @Test + void skipsEventHandlingIfGenerationNotIncreased() { + TestCustomResource customResource = TestUtils.testCustomResource(); + customResource.getMetadata().setFinalizers(List.of(FINALIZER)); + customResource.getMetadata().setGeneration(2L); + + TestCustomResource oldCustomResource = TestUtils.testCustomResource(); + oldCustomResource.getMetadata().setFinalizers(List.of(FINALIZER)); + + source.eventReceived(ResourceAction.UPDATED, customResource, oldCustomResource); + verify(eventHandler, times(1)).handleEvent(any()); + + source.eventReceived(ResourceAction.UPDATED, customResource, customResource); + verify(eventHandler, times(1)).handleEvent(any()); + } + + @Test + void dontSkipEventHandlingIfMarkedForDeletion() { + TestCustomResource customResource1 = TestUtils.testCustomResource(); + + source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1); + verify(eventHandler, times(1)).handleEvent(any()); + + // mark for deletion + customResource1.getMetadata().setDeletionTimestamp(LocalDateTime.now().toString()); + source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1); + verify(eventHandler, times(2)).handleEvent(any()); + } + + @Test + void normalExecutionIfGenerationChanges() { + TestCustomResource customResource1 = TestUtils.testCustomResource(); + + source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1); + verify(eventHandler, times(1)).handleEvent(any()); + + customResource1.getMetadata().setGeneration(2L); + source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1); + verify(eventHandler, times(2)).handleEvent(any()); + } + + @Test + void handlesAllEventIfNotGenerationAware() { + source = new ControllerEventSource<>(new TestController(false)); + setup(); + + TestCustomResource customResource1 = TestUtils.testCustomResource(); + + source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1); + verify(eventHandler, times(1)).handleEvent(any()); + + source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1); + verify(eventHandler, times(2)).handleEvent(any()); + } + + @Test + void eventWithNoGenerationProcessedIfNoFinalizer() { + TestCustomResource customResource1 = TestUtils.testCustomResource(); + + source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1); + + verify(eventHandler, times(1)).handleEvent(any()); + } + + @Test + void callsBroadcastsOnResourceEvents() { + TestCustomResource customResource1 = TestUtils.testCustomResource(); + + source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1); + + verify(testController.getEventSourceManager(), times(1)) + .broadcastOnResourceEvent( + eq(ResourceAction.UPDATED), eq(customResource1), eq(customResource1)); + } + + @Test + void filtersOutEventsOnAddAndUpdate() { + TestCustomResource cr = TestUtils.testCustomResource(); + + OnAddFilter onAddFilter = (res) -> false; + OnUpdateFilter onUpdatePredicate = (res, res2) -> false; + source = new ControllerEventSource<>(new TestController(onAddFilter, onUpdatePredicate, null)); + setUpSource(source, true, controllerConfig); + + source.eventReceived(ResourceAction.ADDED, cr, null); + source.eventReceived(ResourceAction.UPDATED, cr, cr); + + verify(eventHandler, never()).handleEvent(any()); + } + + @Test + void genericFilterFiltersOutAddUpdateAndDeleteEvents() { + TestCustomResource cr = TestUtils.testCustomResource(); + + source = new ControllerEventSource<>(new TestController(null, null, res -> false)); + setUpSource(source, true, controllerConfig); + + source.eventReceived(ResourceAction.ADDED, cr, null); + source.eventReceived(ResourceAction.UPDATED, cr, cr); + source.eventReceived(ResourceAction.DELETED, cr, cr); + + verify(eventHandler, never()).handleEvent(any()); + } + + @SuppressWarnings("unchecked") + private static class TestController extends Controller { + + private static final Reconciler reconciler = + (resource, context) -> UpdateControl.noUpdate(); + + private final EventSourceManager eventSourceManager = + mock(EventSourceManager.class); + + public TestController( + OnAddFilter onAddFilter, + OnUpdateFilter onUpdateFilter, + GenericFilter genericFilter) { + super( + reconciler, + new TestConfiguration(true, onAddFilter, onUpdateFilter, genericFilter), + MockKubernetesClient.client(TestCustomResource.class)); + } + + public TestController(boolean generationAware) { + super( + reconciler, + new TestConfiguration(generationAware, null, null, null), + MockKubernetesClient.client(TestCustomResource.class)); + } + + @Override + public EventSourceManager getEventSourceManager() { + return eventSourceManager; + } + + @Override + public boolean useFinalizer() { + return true; + } + } + + private static class TestConfiguration + extends ResolvedControllerConfiguration { + + public TestConfiguration( + boolean generationAware, + OnAddFilter onAddFilter, + OnUpdateFilter onUpdateFilter, + GenericFilter genericFilter) { + super( + "test", + generationAware, + null, + null, + null, + null, + FINALIZER, + null, + null, + new BaseConfigurationService(), + InformerConfiguration.builder(TestCustomResource.class) + .withOnAddFilter(onAddFilter) + .withOnUpdateFilter(onUpdateFilter) + .withGenericFilter(genericFilter) + .buildForController()); + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFiltersTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFiltersTest.java new file mode 100644 index 0000000000..f1e69c5c2d --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFiltersTest.java @@ -0,0 +1,83 @@ +package io.javaoperatorsdk.operator.processing.event.source.controller; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Service; +import io.javaoperatorsdk.operator.TestUtils; + +import static io.javaoperatorsdk.operator.TestUtils.markForDeletion; +import static org.assertj.core.api.Assertions.assertThat; + +class InternalEventFiltersTest { + + public static final String FINALIZER = "finalizer"; + + @Test + void onUpdateMarkedForDeletion() { + var oldRes = TestUtils.testCustomResource(); + var res = markForDeletion(TestUtils.testCustomResource()); + assertThat(InternalEventFilters.onUpdateMarkedForDeletion().accept(res, oldRes)).isTrue(); + } + + @Test + void generationAware() { + var res = TestUtils.testCustomResource1(); + var res2 = TestUtils.testCustomResource1(); + res2.getMetadata().setGeneration(2L); + + assertThat(InternalEventFilters.onUpdateGenerationAware(true).accept(res2, res)).isTrue(); + assertThat(InternalEventFilters.onUpdateGenerationAware(true).accept(res, res)).isFalse(); + assertThat(InternalEventFilters.onUpdateGenerationAware(false).accept(res, res)).isTrue(); + } + + @Test + void acceptsEventIfNoGenerationOnResource() { + assertThat( + InternalEventFilters.onUpdateGenerationAware(true).accept(testService(), testService())) + .isTrue(); + } + + @Test + void finalizerCheckedIfConfigured() { + assertThat( + InternalEventFilters.onUpdateFinalizerNeededAndApplied(true, FINALIZER) + .accept(TestUtils.testCustomResource1(), TestUtils.testCustomResource1())) + .isTrue(); + + var res = TestUtils.testCustomResource1(); + res.getMetadata().setFinalizers(List.of(FINALIZER)); + + assertThat( + InternalEventFilters.onUpdateFinalizerNeededAndApplied(true, FINALIZER) + .accept(res, res)) + .isFalse(); + } + + @Test + void acceptsIfFinalizerWasJustAdded() { + var res = TestUtils.testCustomResource1(); + res.getMetadata().setFinalizers(List.of(FINALIZER)); + + assertThat( + InternalEventFilters.onUpdateFinalizerNeededAndApplied(true, "finalizer") + .accept(res, TestUtils.testCustomResource1())) + .isTrue(); + } + + @Test + void dontAcceptIfFinalizerNotUsed() { + assertThat( + InternalEventFilters.onUpdateFinalizerNeededAndApplied(false, FINALIZER) + .accept(TestUtils.testCustomResource1(), TestUtils.testCustomResource1())) + .isFalse(); + } + + Service testService() { + var service = new Service(); + service.setMetadata(new ObjectMetaBuilder().withName("test").withNamespace("default").build()); + return service; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/inbound/CachingInboundEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/inbound/CachingInboundEventSourceTest.java new file mode 100644 index 0000000000..4c547d35c1 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/inbound/CachingInboundEventSourceTest.java @@ -0,0 +1,93 @@ +package io.javaoperatorsdk.operator.processing.event.source.inbound; + +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.processing.event.EventHandler; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.AbstractEventSourceTestBase; +import io.javaoperatorsdk.operator.processing.event.source.CacheKeyMapper; +import io.javaoperatorsdk.operator.processing.event.source.SampleExternalResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CachingInboundEventSourceTest + extends AbstractEventSourceTestBase< + CachingInboundEventSource, EventHandler> { + + @SuppressWarnings("unchecked") + private final CachingInboundEventSource.ResourceFetcher< + SampleExternalResource, TestCustomResource> + supplier = mock(CachingInboundEventSource.ResourceFetcher.class); + + private final TestCustomResource testCustomResource = TestUtils.testCustomResource(); + private final CacheKeyMapper cacheKeyMapper = + r -> r.getName() + "#" + r.getValue(); + + @BeforeEach + public void setup() { + when(supplier.fetchResources(any())).thenReturn(Set.of(SampleExternalResource.testResource1())); + + setUpSource( + new CachingInboundEventSource<>(supplier, SampleExternalResource.class, cacheKeyMapper)); + } + + @Test + void getSecondaryResourceFromCacheOrSupplier() throws InterruptedException { + when(supplier.fetchResources(any())).thenReturn(Set.of(SampleExternalResource.testResource1())); + + var value = source.getSecondaryResources(testCustomResource); + + verify(supplier, times(1)).fetchResources(eq(testCustomResource)); + verify(eventHandler, never()).handleEvent(any()); + assertThat(value).hasSize(1); + + value = source.getSecondaryResources(testCustomResource); + + assertThat(value).hasSize(1); + verify(supplier, times(1)).fetchResources(eq(testCustomResource)); + verify(eventHandler, never()).handleEvent(any()); + + source.handleResourceEvent( + ResourceID.fromResource(testCustomResource), + Set.of(SampleExternalResource.testResource1(), SampleExternalResource.testResource2())); + + verify(supplier, times(1)).fetchResources(eq(testCustomResource)); + value = source.getSecondaryResources(testCustomResource); + assertThat(value).hasSize(2); + } + + @Test + void propagateEventOnDeletedResource() throws InterruptedException { + source.handleResourceEvent( + ResourceID.fromResource(testCustomResource), SampleExternalResource.testResource1()); + source.handleResourceDeleteEvent( + ResourceID.fromResource(testCustomResource), + cacheKeyMapper.keyFor(SampleExternalResource.testResource1())); + source.handleResourceDeleteEvent( + ResourceID.fromResource(testCustomResource), + cacheKeyMapper.keyFor(SampleExternalResource.testResource2())); + + verify(eventHandler, times(2)).handleEvent(any()); + } + + @Test + void propagateEventOnUpdateResources() throws InterruptedException { + source.handleResourceEvent( + ResourceID.fromResource(testCustomResource), + Set.of(SampleExternalResource.testResource1(), SampleExternalResource.testResource2())); + + verify(eventHandler, times(1)).handleEvent(any()); + } +} 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 new file mode 100644 index 0000000000..a08989c8ce --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -0,0 +1,216 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +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.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.InformerStoppedHandler; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +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.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_NAMESPACES_SET; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SuppressWarnings({"rawtypes", "unchecked"}) +class InformerEventSourceTest { + + private static final String PREV_RESOURCE_VERSION = "0"; + private static final String DEFAULT_RESOURCE_VERSION = "1"; + + private InformerEventSource informerEventSource; + private final KubernetesClient clientMock = MockKubernetesClient.client(Deployment.class); + private final TemporaryResourceCache temporaryResourceCacheMock = + mock(TemporaryResourceCache.class); + private final EventHandler eventHandlerMock = mock(EventHandler.class); + private final InformerEventSourceConfiguration informerEventSourceConfiguration = + mock(InformerEventSourceConfiguration.class); + + @BeforeEach + void setup() { + final var informerConfig = mock(InformerConfiguration.class); + when(informerEventSourceConfiguration.getInformerConfig()).thenReturn(informerConfig); + when(informerConfig.getEffectiveNamespaces(any())).thenReturn(DEFAULT_NAMESPACES_SET); + when(informerEventSourceConfiguration.getSecondaryToPrimaryMapper()) + .thenReturn(mock(SecondaryToPrimaryMapper.class)); + when(informerEventSourceConfiguration.getResourceClass()).thenReturn(Deployment.class); + + informerEventSource = + new InformerEventSource<>(informerEventSourceConfiguration, clientMock) { + // mocking start + @Override + public synchronized void start() {} + }; + + var mockControllerConfig = mock(ControllerConfiguration.class); + when(mockControllerConfig.getConfigurationService()).thenReturn(new BaseConfigurationService()); + + informerEventSource.setEventHandler(eventHandlerMock); + informerEventSource.setControllerConfiguration(mockControllerConfig); + SecondaryToPrimaryMapper secondaryToPrimaryMapper = mock(SecondaryToPrimaryMapper.class); + when(informerEventSourceConfiguration.getSecondaryToPrimaryMapper()) + .thenReturn(secondaryToPrimaryMapper); + when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any())) + .thenReturn(Set.of(ResourceID.fromResource(testDeployment()))); + informerEventSource.start(); + informerEventSource.setTemporalResourceCache(temporaryResourceCacheMock); + } + + @Test + void skipsEventPropagationIfResourceWithSameVersionInResourceCache() { + when(temporaryResourceCacheMock.getResourceFromCache(any())) + .thenReturn(Optional.of(testDeployment())); + + 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() { + informerEventSource.onUpdate(testDeployment(), testDeployment()); + + verify(eventHandlerMock, times(1)).handleEvent(any()); + } + + @Test + void processEventPropagationWithIncorrectAnnotation() { + informerEventSource.onAdd( + new DeploymentBuilder(testDeployment()) + .editMetadata() + .addToAnnotations(InformerEventSource.PREVIOUS_ANNOTATION_KEY, "invalid") + .endMetadata() + .build()); + + verify(eventHandlerMock, times(1)).handleEvent(any()); + } + + @Test + void propagateEventAndRemoveResourceFromTempCacheIfResourceVersionMismatch() { + Deployment cachedDeployment = testDeployment(); + cachedDeployment.getMetadata().setResourceVersion(PREV_RESOURCE_VERSION); + when(temporaryResourceCacheMock.getResourceFromCache(any())) + .thenReturn(Optional.of(cachedDeployment)); + + informerEventSource.onUpdate(cachedDeployment, testDeployment()); + + verify(eventHandlerMock, times(1)).handleEvent(any()); + verify(temporaryResourceCacheMock, times(1)).onAddOrUpdateEvent(testDeployment()); + } + + @Test + void genericFilterForEvents() { + informerEventSource.setGenericFilter(r -> false); + when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + + informerEventSource.onAdd(testDeployment()); + informerEventSource.onUpdate(testDeployment(), testDeployment()); + informerEventSource.onDelete(testDeployment(), true); + + verify(eventHandlerMock, never()).handleEvent(any()); + } + + @Test + void filtersOnAddEvents() { + informerEventSource.setOnAddFilter(r -> false); + when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + + informerEventSource.onAdd(testDeployment()); + + verify(eventHandlerMock, never()).handleEvent(any()); + } + + @Test + void filtersOnUpdateEvents() { + informerEventSource.setOnUpdateFilter((r1, r2) -> false); + when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + + informerEventSource.onUpdate(testDeployment(), testDeployment()); + + verify(eventHandlerMock, never()).handleEvent(any()); + } + + @Test + void filtersOnDeleteEvents() { + informerEventSource.setOnDeleteFilter((r, b) -> false); + when(temporaryResourceCacheMock.getResourceFromCache(any())).thenReturn(Optional.empty()); + + informerEventSource.onDelete(testDeployment(), true); + + verify(eventHandlerMock, never()).handleEvent(any()); + } + + @Test + void informerStoppedHandlerShouldBeCalledWhenInformerStops() { + final var exception = new RuntimeException("Informer stopped exceptionally!"); + final var informerStoppedHandler = mock(InformerStoppedHandler.class); + var configuration = + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), + o -> o.withInformerStoppedHandler(informerStoppedHandler)); + + var mockControllerConfig = mock(ControllerConfiguration.class); + when(mockControllerConfig.getConfigurationService()).thenReturn(configuration); + + informerEventSource = + new InformerEventSource<>( + informerEventSourceConfiguration, + MockKubernetesClient.client( + Deployment.class, + unused -> { + throw exception; + })); + informerEventSource.setControllerConfiguration(mockControllerConfig); + + // by default informer fails to start if there is an exception in the client on start. + // Throws the exception further. + assertThrows(OperatorException.class, () -> informerEventSource.start()); + verify(informerStoppedHandler, atLeastOnce()).onStop(any(), eq(exception)); + } + + Deployment testDeployment() { + Deployment deployment = new Deployment(); + deployment.setMetadata(new ObjectMeta()); + deployment.getMetadata().setResourceVersion(DEFAULT_RESOURCE_VERSION); + deployment.getMetadata().setName("test"); + return deployment; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java new file mode 100644 index 0000000000..fe091c9698 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java @@ -0,0 +1,80 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.UUID; + +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.TestUtils; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResourceOtherV1; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class MappersTest { + + @Test + void secondaryToPrimaryMapperFromOwnerReference() { + var primary = TestUtils.testCustomResource(); + primary.getMetadata().setUid(UUID.randomUUID().toString()); + var secondary = getConfigMap(primary); + secondary.addOwnerReference(primary); + + var res = Mappers.fromOwnerReferences(TestCustomResource.class).toPrimaryResourceIDs(secondary); + + assertThat(res).contains(ResourceID.fromResource(primary)); + } + + @Test + void secondaryToPrimaryMapperFromOwnerReferenceWhereGroupIdIsEmpty() { + var primary = + new ConfigMapBuilder() + .withNewMetadata() + .withName("test") + .withNamespace("default") + .endMetadata() + .build(); + primary.getMetadata().setUid(UUID.randomUUID().toString()); + var secondary = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName("test1") + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .build(); + secondary.addOwnerReference(primary); + + var res = Mappers.fromOwnerReferences(ConfigMap.class).toPrimaryResourceIDs(secondary); + + assertThat(res).contains(ResourceID.fromResource(primary)); + } + + @Test + void secondaryToPrimaryMapperFromOwnerReferenceFiltersByType() { + var primary = TestUtils.testCustomResource(); + primary.getMetadata().setUid(UUID.randomUUID().toString()); + var secondary = getConfigMap(primary); + secondary.addOwnerReference(primary); + + var res = + Mappers.fromOwnerReferences(TestCustomResourceOtherV1.class) + .toPrimaryResourceIDs(secondary); + + assertThat(res).isEmpty(); + } + + private static ConfigMap getConfigMap(TestCustomResource primary) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName("test1") + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .build(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java new file mode 100644 index 0000000000..7343b1e581 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java @@ -0,0 +1,97 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +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.ObjectMeta; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; + +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; + +class PrimaryToSecondaryIndexTest { + + @SuppressWarnings("unchecked") + private final SecondaryToPrimaryMapper secondaryToPrimaryMapperMock = + mock(SecondaryToPrimaryMapper.class); + + private final PrimaryToSecondaryIndex primaryToSecondaryIndex = + new DefaultPrimaryToSecondaryIndex<>(secondaryToPrimaryMapperMock); + + private final ResourceID primaryID1 = new ResourceID("id1", "default"); + private final ResourceID primaryID2 = new ResourceID("id2", "default"); + private final ConfigMap secondary1 = secondary("secondary1"); + private final ConfigMap secondary2 = secondary("secondary2"); + + @BeforeEach + void setup() { + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(any())) + .thenReturn(Set.of(primaryID1, primaryID2)); + } + + @Test + void returnsEmptySetOnEmptyIndex() { + var res = primaryToSecondaryIndex.getSecondaryResources(ResourceID.fromResource(secondary1)); + assertThat(res).isEmpty(); + } + + @Test + void indexesNewResources() { + primaryToSecondaryIndex.onAddOrUpdate(secondary1); + + var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); + var secondaryResources2 = primaryToSecondaryIndex.getSecondaryResources(primaryID2); + + assertThat(secondaryResources1).containsOnly(ResourceID.fromResource(secondary1)); + assertThat(secondaryResources2).containsOnly(ResourceID.fromResource(secondary1)); + } + + @Test + void indexesAdditionalResources() { + primaryToSecondaryIndex.onAddOrUpdate(secondary1); + primaryToSecondaryIndex.onAddOrUpdate(secondary2); + + var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); + var secondaryResources2 = primaryToSecondaryIndex.getSecondaryResources(primaryID2); + + assertThat(secondaryResources1) + .containsOnly(ResourceID.fromResource(secondary1), ResourceID.fromResource(secondary2)); + assertThat(secondaryResources2) + .containsOnly(ResourceID.fromResource(secondary1), ResourceID.fromResource(secondary2)); + } + + @Test + void removingResourceFromIndex() { + primaryToSecondaryIndex.onAddOrUpdate(secondary1); + primaryToSecondaryIndex.onAddOrUpdate(secondary2); + primaryToSecondaryIndex.onDelete(secondary1); + + var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); + var secondaryResources2 = primaryToSecondaryIndex.getSecondaryResources(primaryID2); + + assertThat(secondaryResources1).containsOnly(ResourceID.fromResource(secondary2)); + assertThat(secondaryResources2).containsOnly(ResourceID.fromResource(secondary2)); + + primaryToSecondaryIndex.onDelete(secondary2); + + secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); + secondaryResources2 = primaryToSecondaryIndex.getSecondaryResources(primaryID2); + + assertThat(secondaryResources1).isEmpty(); + assertThat(secondaryResources2).isEmpty(); + } + + ConfigMap secondary(String name) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new ObjectMeta()); + configMap.getMetadata().setName(name); + configMap.getMetadata().setNamespace("default"); + return configMap; + } +} 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 new file mode 100644 index 0000000000..e62888832f --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryPrimaryResourceCacheTest.java @@ -0,0 +1,187 @@ +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; + +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.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.ExpirationCache; + +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; + +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); + } + + @Test + void updateAddsTheResourceIntoCacheIfTheInformerHasThePreviousResourceVersion() { + var testResource = testResource(); + var prevTestResource = testResource(); + prevTestResource.getMetadata().setResourceVersion("0"); + when(informerEventSource.get(any())).thenReturn(Optional.of(prevTestResource)); + + temporaryResourceCache.putResource(testResource, "0"); + + var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); + assertThat(cached).isPresent(); + } + + @Test + void updateNotAddsTheResourceIntoCacheIfTheInformerHasOtherVersion() { + var testResource = testResource(); + var informerCachedResource = testResource(); + informerCachedResource.getMetadata().setResourceVersion("x"); + when(informerEventSource.get(any())).thenReturn(Optional.of(informerCachedResource)); + + temporaryResourceCache.putResource(testResource, "0"); + + var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); + assertThat(cached).isNotPresent(); + } + + @Test + void addOperationAddsTheResourceIfInformerCacheStillEmpty() { + var testResource = testResource(); + when(informerEventSource.get(any())).thenReturn(Optional.empty()); + + temporaryResourceCache.putAddedResource(testResource); + + var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); + assertThat(cached).isPresent(); + } + + @Test + void addOperationNotAddsTheResourceIfInformerCacheNotEmpty() { + var testResource = testResource(); + when(informerEventSource.get(any())).thenReturn(Optional.of(testResource())); + + temporaryResourceCache.putAddedResource(testResource); + + var cached = temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource)); + assertThat(cached).isNotPresent(); + } + + @Test + void removesResourceFromCache() { + ConfigMap testResource = propagateTestResourceToCache(); + + temporaryResourceCache.onAddOrUpdateEvent(testResource()); + + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isNotPresent(); + } + + @Test + void resourceVersionParsing() { + this.temporaryResourceCache = new TemporaryResourceCache<>(informerEventSource, true); + + assertThat(temporaryResourceCache.isKnownResourceVersion(testResource())).isFalse(); + + ConfigMap testResource = propagateTestResourceToCache(); + + // an event with a newer version will not remove + temporaryResourceCache.onAddOrUpdateEvent( + new ConfigMapBuilder(testResource) + .editMetadata() + .withResourceVersion("1") + .endMetadata() + .build()); + + assertThat(temporaryResourceCache.isKnownResourceVersion(testResource)).isTrue(); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isPresent(); + + // anything else will remove + temporaryResourceCache.onAddOrUpdateEvent(testResource()); + + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isNotPresent(); + } + + @Test + void rapidDeletion() { + var testResource = testResource(); + + temporaryResourceCache.onAddOrUpdateEvent(testResource); + temporaryResourceCache.onDeleteEvent( + new ConfigMapBuilder(testResource) + .editMetadata() + .withResourceVersion("3") + .endMetadata() + .build(), + false); + temporaryResourceCache.putAddedResource(testResource); + + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isEmpty(); + } + + @Test + void expirationCacheMax() { + ExpirationCache cache = new ExpirationCache<>(2, Integer.MAX_VALUE); + + cache.add(1); + cache.add(2); + cache.add(3); + + assertThat(cache.contains(1)).isFalse(); + assertThat(cache.contains(2)).isTrue(); + assertThat(cache.contains(3)).isTrue(); + } + + @Test + void expirationCacheTtl() { + ExpirationCache cache = new ExpirationCache<>(2, 1); + + cache.add(1); + cache.add(2); + + Awaitility.await() + .atMost(1, TimeUnit.SECONDS) + .untilAsserted( + () -> { + assertThat(cache.contains(1)).isFalse(); + assertThat(cache.contains(2)).isFalse(); + }); + } + + private ConfigMap propagateTestResourceToCache() { + var testResource = testResource(); + when(informerEventSource.get(any())).thenReturn(Optional.empty()); + temporaryResourceCache.putAddedResource(testResource); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) + .isPresent(); + return testResource; + } + + ConfigMap testResource() { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new ObjectMetaBuilder().withLabels(Map.of("k", "v")).build()); + configMap.getMetadata().setName("test"); + configMap.getMetadata().setNamespace("default"); + configMap.getMetadata().setResourceVersion(RESOURCE_VERSION); + configMap.getMetadata().setUid("test-uid"); + return configMap; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStoreTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStoreTest.java new file mode 100644 index 0000000000..ae25d16d9a --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TransformingItemStoreTest.java @@ -0,0 +1,64 @@ +package io.javaoperatorsdk.operator.processing.event.source.informer; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; + +import static io.fabric8.kubernetes.client.informers.cache.Cache.metaNamespaceKeyFunc; +import static org.assertj.core.api.Assertions.assertThat; + +class TransformingItemStoreTest { + + @Test + void cachedObjectTransformed() { + TransformingItemStore transformingItemStore = + new TransformingItemStore<>( + r -> { + r.getMetadata().setLabels(null); + return r; + }); + + var cm = configMap(); + cm.getMetadata().setLabels(Map.of("k", "v")); + transformingItemStore.put(metaNamespaceKeyFunc(cm), cm); + + assertThat(transformingItemStore.get(metaNamespaceKeyFunc(cm)).getMetadata().getLabels()) + .isNull(); + } + + @Test + void preservesSelectedAttributes() { + TransformingItemStore transformingItemStore = + new TransformingItemStore<>( + r -> { + r.getMetadata().setName(null); + r.getMetadata().setNamespace(null); + r.getMetadata().setResourceVersion(null); + return r; + }); + var cm = configMap(); + transformingItemStore.put(metaNamespaceKeyFunc(cm), cm); + + assertThat(transformingItemStore.get(metaNamespaceKeyFunc(cm)).getMetadata().getName()) + .isNotNull(); + assertThat(transformingItemStore.get(metaNamespaceKeyFunc(cm)).getMetadata().getNamespace()) + .isNotNull(); + assertThat( + transformingItemStore.get(metaNamespaceKeyFunc(cm)).getMetadata().getResourceVersion()) + .isNotNull(); + } + + ConfigMap configMap() { + var cm = new ConfigMap(); + cm.setMetadata( + new ObjectMetaBuilder() + .withName("test1") + .withNamespace("default") + .withResourceVersion("1") + .build()); + return cm; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSourceTest.java new file mode 100644 index 0000000000..c85f40d5ad --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSourceTest.java @@ -0,0 +1,219 @@ +package io.javaoperatorsdk.operator.processing.event.source.polling; + +import java.time.Duration; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.processing.event.EventHandler; +import io.javaoperatorsdk.operator.processing.event.source.AbstractEventSourceTestBase; +import io.javaoperatorsdk.operator.processing.event.source.CacheKeyMapper; +import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache; +import io.javaoperatorsdk.operator.processing.event.source.SampleExternalResource; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class PerResourcePollingEventSourceTest + extends AbstractEventSourceTestBase< + PerResourcePollingEventSource, EventHandler> { + + public static final int PERIOD = 150; + + @SuppressWarnings("unchecked") + private final PerResourcePollingEventSource.ResourceFetcher< + SampleExternalResource, TestCustomResource> + supplier = mock(PerResourcePollingEventSource.ResourceFetcher.class); + + @SuppressWarnings("unchecked") + private final IndexerResourceCache resourceCache = + mock(IndexerResourceCache.class); + + private final TestCustomResource testCustomResource = TestUtils.testCustomResource(); + private final EventSourceContext context = mock(EventSourceContext.class); + + @BeforeEach + public void setup() { + when(resourceCache.get(any())).thenReturn(Optional.of(testCustomResource)); + when(supplier.fetchResources(any())).thenReturn(Set.of(SampleExternalResource.testResource1())); + when(context.getPrimaryCache()).thenReturn(resourceCache); + + setUpSource( + new PerResourcePollingEventSource<>( + SampleExternalResource.class, + context, + new PerResourcePollingConfigurationBuilder<>(supplier, Duration.ofMillis(PERIOD)) + .withCacheKeyMapper(r -> r.getName() + "#" + r.getValue()) + .build())); + } + + @Test + void pollsTheResourceAfterAwareOfIt() { + source.onResourceCreated(testCustomResource); + + await() + .pollDelay(Duration.ofMillis(3 * PERIOD)) + .untilAsserted( + () -> { + verify(supplier, atLeast(2)).fetchResources(eq(testCustomResource)); + verify(supplier, atLeast(2)).fetchDelay(any(), eq(testCustomResource)); + verify(eventHandler, times(1)).handleEvent(any()); + }); + } + + @Test + void registeringTaskOnAPredicate() { + setUpSource( + new PerResourcePollingEventSource<>( + SampleExternalResource.class, + context, + new PerResourcePollingConfigurationBuilder<>(supplier, Duration.ofMillis(PERIOD)) + .withRegisterPredicate( + testCustomResource -> testCustomResource.getMetadata().getGeneration() > 1) + .withCacheKeyMapper(CacheKeyMapper.singleResourceCacheKeyMapper()) + .build())); + + source.onResourceCreated(testCustomResource); + + await() + .pollDelay(Duration.ofMillis(2 * PERIOD)) + .untilAsserted(() -> verify(supplier, times(0)).fetchResources(eq(testCustomResource))); + + testCustomResource.getMetadata().setGeneration(2L); + source.onResourceUpdated(testCustomResource, testCustomResource); + + await() + .pollDelay(Duration.ofMillis(2 * PERIOD)) + .untilAsserted(() -> verify(supplier, atLeast(1)).fetchResources(eq(testCustomResource))); + } + + @Test + void propagateEventOnDeletedResource() { + source.onResourceCreated(testCustomResource); + when(supplier.fetchResources(any())) + .thenReturn(Set.of(SampleExternalResource.testResource1())) + .thenReturn(Collections.emptySet()); + + await() + .pollDelay(Duration.ofMillis(3 * PERIOD)) + .untilAsserted( + () -> { + verify(supplier, atLeast(2)).fetchResources(eq(testCustomResource)); + verify(eventHandler, times(2)).handleEvent(any()); + }); + } + + @Test + void getSecondaryResourceInitiatesFetchJustForFirstTime() { + source.onResourceCreated(testCustomResource); + when(supplier.fetchResources(any())) + .thenReturn(Set.of(SampleExternalResource.testResource1())) + .thenReturn( + Set.of(SampleExternalResource.testResource1(), SampleExternalResource.testResource2())); + + var value = source.getSecondaryResources(testCustomResource); + + verify(supplier, times(1)).fetchResources(eq(testCustomResource)); + verify(eventHandler, never()).handleEvent(any()); + assertThat(value).hasSize(1); + + value = source.getSecondaryResources(testCustomResource); + + assertThat(value).hasSize(1); + verify(supplier, times(1)).fetchResources(eq(testCustomResource)); + verify(eventHandler, never()).handleEvent(any()); + + await() + .pollDelay(Duration.ofMillis(PERIOD * 2)) + .untilAsserted( + () -> { + verify(supplier, atLeast(2)).fetchResources(eq(testCustomResource)); + var val = source.getSecondaryResources(testCustomResource); + assertThat(val).hasSize(2); + }); + } + + @Test + void getsValueFromCacheOrSupplier() { + source.onResourceCreated(testCustomResource); + when(supplier.fetchResources(any())) + .thenReturn(Collections.emptySet()) + .thenReturn(Set.of(SampleExternalResource.testResource1())); + + await() + .pollDelay(Duration.ofMillis(PERIOD / 3)) + .untilAsserted( + () -> { + var value = source.getSecondaryResources(testCustomResource); + verify(eventHandler, times(0)).handleEvent(any()); + assertThat(value).isEmpty(); + }); + + await() + .pollDelay(Duration.ofMillis(PERIOD * 2)) + .untilAsserted( + () -> { + var value2 = source.getSecondaryResources(testCustomResource); + assertThat(value2).hasSize(1); + verify(eventHandler, times(1)).handleEvent(any()); + }); + } + + @Test + void supportsDynamicPollingDelay() { + when(supplier.fetchResources(any())).thenReturn(Set.of(SampleExternalResource.testResource1())); + when(supplier.fetchDelay(any(), any())) + .thenReturn(Optional.of(Duration.ofMillis(PERIOD))) + .thenReturn(Optional.of(Duration.ofMillis(PERIOD * 2))); + + source.onResourceCreated(testCustomResource); + + await() + .pollDelay(Duration.ofMillis(PERIOD)) + .atMost(Duration.ofMillis((long) (1.5 * PERIOD))) + .pollInterval(Duration.ofMillis(20)) + .untilAsserted(() -> verify(supplier, times(1)).fetchResources(any())); + // verifying that it is not called as with normal interval + await() + .pollDelay(Duration.ofMillis(PERIOD)) + .atMost(Duration.ofMillis((long) (1.5 * PERIOD))) + .pollInterval(Duration.ofMillis(20)) + .untilAsserted(() -> verify(supplier, times(1)).fetchResources(any())); + await() + .pollDelay(Duration.ofMillis(PERIOD)) + .atMost(Duration.ofMillis(2 * PERIOD)) + .pollInterval(Duration.ofMillis(20)) + .untilAsserted(() -> verify(supplier, times(2)).fetchResources(any())); + } + + @Test + void deleteEventCancelsTheScheduling() { + when(supplier.fetchResources(any())).thenReturn(Set.of(SampleExternalResource.testResource1())); + + source.onResourceCreated(testCustomResource); + + await() + .pollDelay(Duration.ofMillis(PERIOD)) + .atMost(Duration.ofMillis((2 * PERIOD))) + .pollInterval(Duration.ofMillis(20)) + .untilAsserted(() -> verify(supplier, times(1)).fetchResources(any())); + + when(resourceCache.get(any())).thenReturn(Optional.empty()); + source.onResourceDeleted(testCustomResource); + + // check if not called again + await() + .pollDelay(Duration.ofMillis(2 * PERIOD)) + .atMost(Duration.ofMillis((4 * PERIOD))) + .untilAsserted(() -> verify(supplier, times(1)).fetchResources(any())); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSourceTest.java new file mode 100644 index 0000000000..0f7d26446d --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/polling/PollingEventSourceTest.java @@ -0,0 +1,128 @@ +package io.javaoperatorsdk.operator.processing.event.source.polling; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.health.Status; +import io.javaoperatorsdk.operator.processing.event.EventHandler; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.AbstractEventSourceTestBase; +import io.javaoperatorsdk.operator.processing.event.source.SampleExternalResource; + +import static io.javaoperatorsdk.operator.processing.event.source.SampleExternalResource.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.*; + +class PollingEventSourceTest + extends AbstractEventSourceTestBase< + PollingEventSource, EventHandler> { + + public static final int DEFAULT_WAIT_PERIOD = 100; + public static final Duration POLL_PERIOD = Duration.ofMillis(30L); + + @SuppressWarnings("unchecked") + private final PollingEventSource.GenericResourceFetcher resourceFetcher = + mock(PollingEventSource.GenericResourceFetcher.class); + + private final PollingEventSource pollingEventSource = + new PollingEventSource<>( + SampleExternalResource.class, + new PollingConfiguration<>( + null, + resourceFetcher, + POLL_PERIOD, + (SampleExternalResource er) -> er.getName() + "#" + er.getValue())); + + @BeforeEach + public void setup() { + setUpSource(pollingEventSource, false); + } + + @Test + void pollsAndProcessesEvents() throws InterruptedException { + when(resourceFetcher.fetchResources()).thenReturn(testResponseWithTwoValues()); + pollingEventSource.start(); + Thread.sleep(DEFAULT_WAIT_PERIOD); + + verify(eventHandler, times(2)).handleEvent(any()); + } + + @Test + void propagatesEventForRemovedResources() throws InterruptedException { + when(resourceFetcher.fetchResources()) + .thenReturn(testResponseWithTwoValues()) + .thenReturn(testResponseWithOneValue()); + pollingEventSource.start(); + Thread.sleep(DEFAULT_WAIT_PERIOD); + + verify(eventHandler, times(3)).handleEvent(any()); + } + + @Test + void doesNotPropagateEventIfResourceNotChanged() throws InterruptedException { + when(resourceFetcher.fetchResources()).thenReturn(testResponseWithTwoValues()); + pollingEventSource.start(); + Thread.sleep(DEFAULT_WAIT_PERIOD); + + verify(eventHandler, times(2)).handleEvent(any()); + } + + @Test + void propagatesEventOnNewResourceForPrimary() throws InterruptedException { + when(resourceFetcher.fetchResources()) + .thenReturn(testResponseWithOneValue()) + .thenReturn(testResponseWithTwoValueForSameId()); + + pollingEventSource.start(); + Thread.sleep(DEFAULT_WAIT_PERIOD); + + verify(eventHandler, times(2)).handleEvent(any()); + } + + @Test + void updatesHealthIndicatorBasedOnExceptionsInFetcher() throws InterruptedException { + when(resourceFetcher.fetchResources()).thenReturn(testResponseWithOneValue()); + pollingEventSource.start(); + assertThat(pollingEventSource.getStatus()).isEqualTo(Status.HEALTHY); + + when(resourceFetcher.fetchResources()) + // 2x - to make sure to catch the health indicator change + .thenThrow(new RuntimeException("test exception")) + .thenThrow(new RuntimeException("test exception")) + .thenReturn(testResponseWithOneValue()); + + await() + .pollInterval(POLL_PERIOD) + .untilAsserted( + () -> assertThat(pollingEventSource.getStatus()).isEqualTo(Status.UNHEALTHY)); + + await() + .untilAsserted(() -> assertThat(pollingEventSource.getStatus()).isEqualTo(Status.HEALTHY)); + } + + private Map> testResponseWithTwoValueForSameId() { + Map> res = new HashMap<>(); + res.put(primaryID1(), Set.of(testResource1(), testResource2())); + return res; + } + + private Map> testResponseWithOneValue() { + Map> res = new HashMap<>(); + res.put(primaryID1(), Set.of(testResource1())); + return res; + } + + private Map> testResponseWithTwoValues() { + Map> res = new HashMap<>(); + res.put(primaryID1(), Set.of(testResource1())); + res.put(primaryID2(), Set.of(testResource2())); + return res; + } +} 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 new file mode 100644 index 0000000000..9396411777 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSourceTest.java @@ -0,0 +1,125 @@ +package io.javaoperatorsdk.operator.processing.event.source.timer; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionFactory; +import org.awaitility.core.ThrowingRunnable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.javaoperatorsdk.operator.TestUtils; +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.AbstractEventSourceTestBase; +import io.javaoperatorsdk.operator.processing.event.source.timer.TimerEventSourceTest.CapturingEventHandler; +import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class TimerEventSourceTest + extends AbstractEventSourceTestBase< + TimerEventSource, CapturingEventHandler> { + + public static final int INITIAL_DELAY = 50; + public static final int PERIOD = 50; + + @BeforeEach + public void setup() { + setUpSource(new TimerEventSource<>(), new CapturingEventHandler()); + } + + @Test + public void schedulesOnce() { + var resourceID = ResourceID.fromResource(TestUtils.testCustomResource()); + + source.scheduleOnce(resourceID, PERIOD); + + untilAsserted(() -> assertThat(eventHandler.events).hasSize(1)); + untilAsserted(PERIOD * 2, 0, () -> assertThat(eventHandler.events).hasSize(1)); + } + + @Test + public void canCancelOnce() { + var resourceID = ResourceID.fromResource(TestUtils.testCustomResource()); + + source.scheduleOnce(resourceID, PERIOD); + source.cancelOnceSchedule(resourceID); + + untilAsserted(() -> assertThat(eventHandler.events).isEmpty()); + } + + @Test + public void canRescheduleOnceEvent() { + var resourceID = ResourceID.fromResource(TestUtils.testCustomResource()); + + source.scheduleOnce(resourceID, PERIOD); + source.scheduleOnce(resourceID, 2 * PERIOD); + + untilAsserted(PERIOD * 2, PERIOD, () -> assertThat(eventHandler.events).hasSize(1)); + } + + @Test + public void deRegistersOnceEventSources() { + TestCustomResource customResource = TestUtils.testCustomResource(); + + source.scheduleOnce(ResourceID.fromResource(customResource), PERIOD); + source.onResourceDeleted(customResource); + + untilAsserted(() -> assertThat(eventHandler.events).isEmpty()); + } + + @Test + public void eventNotRegisteredIfStopped() throws IOException { + var resourceID = ResourceID.fromResource(TestUtils.testCustomResource()); + + source.stop(); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> source.scheduleOnce(resourceID, PERIOD)); + } + + @Test + public void eventNotFiredIfStopped() throws IOException { + source.scheduleOnce(ResourceID.fromResource(TestUtils.testCustomResource()), PERIOD); + source.stop(); + + untilAsserted(() -> assertThat(eventHandler.events).isEmpty()); + } + + private void untilAsserted(ThrowingRunnable assertion) { + untilAsserted(INITIAL_DELAY, PERIOD, assertion); + } + + private void untilAsserted(long initialDelay, long interval, ThrowingRunnable assertion) { + long delay = INITIAL_DELAY; + long period = PERIOD; + + ConditionFactory cf = Awaitility.await(); + + if (initialDelay > 0) { + delay = initialDelay; + cf = cf.pollDelay(initialDelay, TimeUnit.MILLISECONDS); + } + if (interval > 0) { + period = interval; + cf = cf.pollInterval(interval, TimeUnit.MILLISECONDS); + } + + cf = cf.atMost(delay + (period * 3), TimeUnit.MILLISECONDS); + cf.untilAsserted(assertion); + } + + public static class CapturingEventHandler implements EventHandler { + private final List events = new CopyOnWriteArrayList<>(); + + @Override + public void handleEvent(Event event) { + events.add(event); + } + } +} 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 new file mode 100644 index 0000000000..1659995877 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java @@ -0,0 +1,71 @@ +package io.javaoperatorsdk.operator.processing.retry; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GenericRetryExecutionTest { + + @Test + public void noNextDelayIfMaxAttemptLimitReached() { + RetryExecution retryExecution = + GenericRetry.defaultLimitedExponentialRetry().setMaxAttempts(3).initExecution(); + Optional res = callNextDelayNTimes(retryExecution, 2); + assertThat(res).isNotEmpty(); + + res = retryExecution.nextDelay(); + assertThat(res).isEmpty(); + } + + @Test + public void canLimitMaxIntervalLength() { + RetryExecution retryExecution = + GenericRetry.defaultLimitedExponentialRetry() + .setInitialInterval(2000) + .setMaxInterval(4500) + .setIntervalMultiplier(2) + .initExecution(); + + Optional res = callNextDelayNTimes(retryExecution, 4); + + assertThat(res.get()).isEqualTo(4500); + } + + @Test + public void supportsNoRetry() { + RetryExecution retryExecution = GenericRetry.noRetry().initExecution(); + assertThat(retryExecution.nextDelay()).isEmpty(); + } + + @Test + public void supportsIsLastExecution() { + GenericRetryExecution execution = new GenericRetry().setMaxAttempts(2).initExecution(); + assertThat(execution.isLastAttempt()).isFalse(); + + execution.nextDelay(); + execution.nextDelay(); + assertThat(execution.isLastAttempt()).isTrue(); + } + + @Test + public void returnAttemptIndex() { + RetryExecution retryExecution = GenericRetry.defaultLimitedExponentialRetry().initExecution(); + + assertThat(retryExecution.getAttemptCount()).isEqualTo(0); + retryExecution.nextDelay(); + assertThat(retryExecution.getAttemptCount()).isEqualTo(1); + } + + private RetryExecution getDefaultRetryExecution() { + return GenericRetry.defaultLimitedExponentialRetry().initExecution(); + } + + public Optional callNextDelayNTimes(RetryExecution retryExecution, int n) { + for (int i = 0; i < n; i++) { + retryExecution.nextDelay(); + } + return retryExecution.nextDelay(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenCustomResource.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenCustomResource.java new file mode 100644 index 0000000000..f06c0035d3 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenCustomResource.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.sample.observedgeneration; + +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk.io") +@Version("v1") +public class ObservedGenCustomResource extends CustomResource { + + @Override + protected ObservedGenSpec initSpec() { + return new ObservedGenSpec(); + } + + @Override + protected ObservedGenStatus initStatus() { + return new ObservedGenStatus(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenSpec.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenSpec.java new file mode 100644 index 0000000000..30fb495c56 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenSpec.java @@ -0,0 +1,19 @@ +package io.javaoperatorsdk.operator.sample.observedgeneration; + +public class ObservedGenSpec { + + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public String toString() { + return "TestCustomResourceSpec{" + "value='" + value + '\'' + '}'; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenStatus.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenStatus.java new file mode 100644 index 0000000000..0f685d5b05 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenStatus.java @@ -0,0 +1,3 @@ +package io.javaoperatorsdk.operator.sample.observedgeneration; + +public class ObservedGenStatus {} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/NamespacedTestCustomResource.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/NamespacedTestCustomResource.java new file mode 100644 index 0000000000..761d91dc04 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/NamespacedTestCustomResource.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.sample.simple; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("namespaced-sample.javaoperatorsdk.io") +@Version("v1") +public class NamespacedTestCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomReconciler.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomReconciler.java new file mode 100644 index 0000000000..1d7535c9d3 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomReconciler.java @@ -0,0 +1,114 @@ +package io.javaoperatorsdk.operator.sample.simple; + +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.*; + +@ControllerConfiguration(generationAwareEventProcessing = false) +public class TestCustomReconciler + implements Reconciler, Cleaner { + + private static final Logger log = LoggerFactory.getLogger(TestCustomReconciler.class); + + public static final String CRD_NAME = CustomResource.getCRDName(TestCustomResource.class); + public static final String FINALIZER_NAME = CRD_NAME + "/finalizer"; + + private final KubernetesClient kubernetesClient; + private final boolean updateStatus; + + public TestCustomReconciler(KubernetesClient kubernetesClient) { + this(kubernetesClient, true); + } + + public TestCustomReconciler(KubernetesClient kubernetesClient, boolean updateStatus) { + this.kubernetesClient = kubernetesClient; + this.updateStatus = updateStatus; + } + + @Override + public DeleteControl cleanup(TestCustomResource resource, Context context) { + var statusDetails = + kubernetesClient + .configMaps() + .inNamespace(resource.getMetadata().getNamespace()) + .withName(resource.getSpec().getConfigMapName()) + .delete(); + if (statusDetails.size() == 1 && statusDetails.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) { + if (!resource.getMetadata().getFinalizers().contains(FINALIZER_NAME)) { + throw new IllegalStateException("Finalizer is not present."); + } + + ConfigMap existingConfigMap = + kubernetesClient + .configMaps() + .inNamespace(resource.getMetadata().getNamespace()) + .withName(resource.getSpec().getConfigMapName()) + .get(); + + if (existingConfigMap != null) { + existingConfigMap.setData(configMapData(resource)); + // existingConfigMap.getMetadata().setResourceVersion(null); + kubernetesClient + .configMaps() + .inNamespace(resource.getMetadata().getNamespace()) + .resource(existingConfigMap) + .createOrReplace(); + } else { + Map labels = new HashMap<>(); + labels.put("managedBy", TestCustomReconciler.class.getSimpleName()); + ConfigMap newConfigMap = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(resource.getSpec().getConfigMapName()) + .withNamespace(resource.getMetadata().getNamespace()) + .withLabels(labels) + .build()) + .withData(configMapData(resource)) + .build(); + kubernetesClient + .configMaps() + .inNamespace(resource.getMetadata().getNamespace()) + .resource(newConfigMap) + .createOrReplace(); + } + if (updateStatus) { + if (resource.getStatus() == null) { + resource.setStatus(new TestCustomResourceStatus()); + } + resource.getStatus().setConfigMapStatus("ConfigMap Ready"); + } + return UpdateControl.patchResource(resource); + } + + private Map configMapData(TestCustomResource resource) { + Map data = new HashMap<>(); + data.put(resource.getSpec().getKey(), resource.getSpec().getValue()); + return data; + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomReconcilerOtherV1.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomReconcilerOtherV1.java new file mode 100644 index 0000000000..5327b30a79 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomReconcilerOtherV1.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.sample.simple; + +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; + +@ControllerConfiguration +public class TestCustomReconcilerOtherV1 implements Reconciler { + + @Override + public UpdateControl reconcile( + TestCustomResourceOtherV1 resource, Context context) { + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResource.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResource.java new file mode 100644 index 0000000000..d01bd3c747 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResource.java @@ -0,0 +1,21 @@ +package io.javaoperatorsdk.operator.sample.simple; + +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk.io") +@Version("v1") +public class TestCustomResource + extends CustomResource { + + @Override + protected TestCustomResourceSpec initSpec() { + return new TestCustomResourceSpec(); + } + + @Override + protected TestCustomResourceStatus initStatus() { + return new TestCustomResourceStatus(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceOtherV1.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceOtherV1.java new file mode 100644 index 0000000000..6bb572ca4e --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceOtherV1.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.sample.simple; + +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.Version; + +@Group("sample.javaoperatorsdk.io") +@Version("v1") +@Kind("TestCustomResourceOtherV1") // this is needed to override the automatically generated kind +public class TestCustomResourceOtherV1 + extends CustomResource {} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceSpec.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceSpec.java new file mode 100644 index 0000000000..69a6b107b2 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceSpec.java @@ -0,0 +1,70 @@ +package io.javaoperatorsdk.operator.sample.simple; + +import java.util.Objects; + +public class TestCustomResourceSpec { + + private String configMapName; + + private String key; + + private String value; + + public String getConfigMapName() { + return configMapName; + } + + public void setConfigMapName(String configMapName) { + this.configMapName = configMapName; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public String toString() { + return "TestCustomResourceSpec{" + + "configMapName='" + + configMapName + + '\'' + + ", key='" + + key + + '\'' + + ", value='" + + value + + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TestCustomResourceSpec that = (TestCustomResourceSpec) o; + return Objects.equals(configMapName, that.configMapName) + && Objects.equals(key, that.key) + && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(configMapName, key, value); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceStatus.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceStatus.java new file mode 100644 index 0000000000..ab5559d80c --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceStatus.java @@ -0,0 +1,38 @@ +package io.javaoperatorsdk.operator.sample.simple; + +import java.util.Objects; + +public class TestCustomResourceStatus { + + private String configMapStatus; + + public String getConfigMapStatus() { + return configMapStatus; + } + + public void setConfigMapStatus(String configMapStatus) { + this.configMapStatus = configMapStatus; + } + + @Override + public String toString() { + return "TestCustomResourceStatus{" + "configMapStatus='" + configMapStatus + '\'' + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TestCustomResourceStatus that = (TestCustomResourceStatus) o; + return Objects.equals(configMapStatus, that.configMapStatus); + } + + @Override + public int hashCode() { + return Objects.hash(configMapStatus); + } +} diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/deployment.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/deployment.yaml new file mode 100644 index 0000000000..ca51b1ec4d --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2 +kind: Deployment +metadata: + name: "" +spec: + progressDeadlineSeconds: 600 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: "test" + replicas: 1 + template: + metadata: + labels: + app: "test" + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/configmap.empty-owner-reference-desired.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/configmap.empty-owner-reference-desired.yaml new file mode 100644 index 0000000000..01d27e39b3 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/configmap.empty-owner-reference-desired.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: test1 + namespace: default + ownerReferences: + - apiVersion: v1 + kind: ConfigMap + name: kube-root-ca.crt + uid: 1ef74cb4-dbbd-45ef-9caf-aa76186594ea +data: + key1: "val1" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/configmap.empty-owner-reference.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/configmap.empty-owner-reference.yaml new file mode 100644 index 0000000000..b4de8f711a --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/configmap.empty-owner-reference.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +data: + key1: "val1" +kind: ConfigMap +metadata: + creationTimestamp: "2023-06-07T11:08:34Z" + managedFields: + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + f:key1: {} + f:metadata: + f:ownerReferences: + k:{"uid":"1ef74cb4-dbbd-45ef-9caf-aa76186594ea"}: {} + manager: controller + operation: Apply + time: "2023-06-07T11:08:34Z" + name: test1 + namespace: default + ownerReferences: + - apiVersion: v1 + kind: ConfigMap + name: kube-root-ca.crt + uid: 1ef74cb4-dbbd-45ef-9caf-aa76186594ea + resourceVersion: "400" + uid: 1d47f98f-ff1e-46d8-bbb5-6658ec488ae2 diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/deployment-with-managed-fields-additional-controller.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/deployment-with-managed-fields-additional-controller.yaml new file mode 100644 index 0000000000..38358a16c0 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/deployment-with-managed-fields-additional-controller.yaml @@ -0,0 +1,106 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + creationTimestamp: "2023-06-01T08:43:47Z" + generation: 2 + managedFields: + - apiVersion: apps/v1 + fieldsType: FieldsV1 + fieldsV1: + f:spec: + f:progressDeadlineSeconds: {} + f:replicas: {} + f:revisionHistoryLimit: {} + f:selector: {} + f:template: + f:metadata: + f:labels: + f:app: {} + f:spec: + f:containers: + k:{"name":"nginx"}: + .: {} + f:image: {} + f:name: {} + f:ports: + .: {} + k:{"containerPort":80,"protocol":"TCP"}: + .: {} + f:containerPort: {} + manager: controller + operation: Apply + time: "2023-06-01T08:43:47Z" + - apiVersion: apps/v1 + fieldsType: FieldsV1 + fieldsV1: + f:metadata: + f:annotations: + .: {} + f:deployment.kubernetes.io/revision: {} + f:status: + f:availableReplicas: {} + f:conditions: + .: {} + k:{"type":"Available"}: + .: {} + f:lastTransitionTime: {} + f:lastUpdateTime: {} + f:message: {} + f:reason: {} + f:status: {} + f:type: {} + k:{"type":"Progressing"}: + .: {} + f:lastTransitionTime: {} + f:lastUpdateTime: {} + f:message: {} + f:reason: {} + f:status: {} + f:type: {} + f:observedGeneration: {} + f:readyReplicas: {} + f:replicas: {} + f:updatedReplicas: {} + manager: kube-controller-manager + operation: Update + subresource: status + time: "2023-06-01T08:43:54Z" + name: test + namespace: default + resourceVersion: "422" + uid: f4572f1d-5fd6-4564-8e61-0d55d0398a6c +spec: + progressDeadlineSeconds: 600 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: test-dependent + strategy: + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + type: RollingUpdate + template: + metadata: + creationTimestamp: null + labels: + app: test-dependent + spec: + containers: + - image: nginx:1.17.0 + imagePullPolicy: IfNotPresent + name: nginx + ports: + - containerPort: 80 + protocol: TCP + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + dnsPolicy: ClusterFirst + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/deployment-with-managed-fields.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/deployment-with-managed-fields.yaml new file mode 100644 index 0000000000..6089fc7882 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/deployment-with-managed-fields.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: "1" + creationTimestamp: "2023-06-01T08:43:47Z" + generation: 1 + managedFields: + - apiVersion: apps/v1 + fieldsType: FieldsV1 + fieldsV1: + f:spec: + f:progressDeadlineSeconds: {} + f:replicas: {} + f:revisionHistoryLimit: {} + f:selector: {} + f:template: + f:metadata: + f:labels: + f:app: {} + f:spec: + f:containers: + k:{"name":"nginx"}: + .: {} + f:image: {} + f:name: {} + f:ports: + k:{"containerPort":80,"protocol":"TCP"}: + .: {} + f:containerPort: {} + manager: controller + operation: Apply + time: "2023-06-01T08:43:47Z" + name: test + namespace: default +spec: + progressDeadlineSeconds: 600 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: "test-dependent" + replicas: 1 + template: + metadata: + labels: + app: "test-dependent" + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/multi-container-pod-desired.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/multi-container-pod-desired.yaml new file mode 100644 index 0000000000..e400532fad --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/multi-container-pod-desired.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Pod +metadata: + name: shared-storage +spec: + volumes: + - name: shared-data + emptyDir: {} + containers: + - name: nginx-container + image: nginx + volumeMounts: + - name: shared-data + mountPath: /usr/share/nginx/html + - name: debian-container + image: debian + volumeMounts: + - name: shared-data + mountPath: /data + command: ["/bin/sh"] + args: ["-c", "echo Level Up Blue Team! > /data/index.html"] diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/multi-container-pod.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/multi-container-pod.yaml new file mode 100644 index 0000000000..6a5f2d82b4 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/multi-container-pod.yaml @@ -0,0 +1,214 @@ +apiVersion: v1 +kind: Pod +metadata: + creationTimestamp: "2023-06-08T11:50:59Z" + managedFields: + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:spec: + f:containers: + k:{"name":"debian-container"}: + .: {} + f:args: {} + f:command: {} + f:image: {} + f:name: {} + f:volumeMounts: + k:{"mountPath":"/data"}: + .: {} + f:mountPath: {} + f:name: {} + k:{"name":"nginx-container"}: + .: {} + f:image: {} + f:name: {} + f:volumeMounts: + k:{"mountPath":"/usr/share/nginx/html"}: + .: {} + f:mountPath: {} + f:name: {} + f:volumes: + k:{"name":"shared-data"}: + .: {} + f:emptyDir: {} + f:name: {} + manager: controller + operation: Apply + time: "2023-06-08T11:50:59Z" + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:status: + f:conditions: + k:{"type":"ContainersReady"}: + .: {} + f:lastProbeTime: {} + f:lastTransitionTime: {} + f:message: {} + f:reason: {} + f:status: {} + f:type: {} + k:{"type":"Initialized"}: + .: {} + f:lastProbeTime: {} + f:lastTransitionTime: {} + f:status: {} + f:type: {} + k:{"type":"Ready"}: + .: {} + f:lastProbeTime: {} + f:lastTransitionTime: {} + f:message: {} + f:reason: {} + f:status: {} + f:type: {} + f:containerStatuses: {} + f:hostIP: {} + f:phase: {} + f:podIP: {} + f:podIPs: + .: {} + k:{"ip":"10.244.0.3"}: + .: {} + f:ip: {} + f:startTime: {} + manager: kubelet + operation: Update + subresource: status + time: "2023-06-08T11:51:21Z" + name: shared-storage + namespace: default + resourceVersion: "1950" + uid: 0c916935-8198-4d62-980e-193f3c3ec877 +spec: + containers: + - image: nginx + imagePullPolicy: Always + name: nginx-container + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + volumeMounts: + - mountPath: /usr/share/nginx/html + name: shared-data + - mountPath: /var/run/secrets/kubernetes.io/serviceaccount + name: kube-api-access-gxpbz + readOnly: true + - args: + - -c + - echo Level Up Blue Team! > /data/index.html + command: + - /bin/sh + image: debian + imagePullPolicy: Always + name: debian-container + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + volumeMounts: + - mountPath: /data + name: shared-data + - mountPath: /var/run/secrets/kubernetes.io/serviceaccount + name: kube-api-access-gxpbz + readOnly: true + dnsPolicy: ClusterFirst + enableServiceLinks: true + nodeName: minikube + preemptionPolicy: PreemptLowerPriority + priority: 0 + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + serviceAccount: default + serviceAccountName: default + terminationGracePeriodSeconds: 30 + tolerations: + - effect: NoExecute + key: node.kubernetes.io/not-ready + operator: Exists + tolerationSeconds: 300 + - effect: NoExecute + key: node.kubernetes.io/unreachable + operator: Exists + tolerationSeconds: 300 + volumes: + - emptyDir: {} + name: shared-data + - name: kube-api-access-gxpbz + projected: + defaultMode: 420 + sources: + - serviceAccountToken: + expirationSeconds: 3607 + path: token + - configMap: + items: + - key: ca.crt + path: ca.crt + name: kube-root-ca.crt + - downwardAPI: + items: + - fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace + path: namespace +status: + conditions: + - lastProbeTime: null + lastTransitionTime: "2023-06-08T11:50:59Z" + status: "True" + type: Initialized + - lastProbeTime: null + lastTransitionTime: "2023-06-08T11:50:59Z" + message: 'containers with unready status: [debian-container]' + reason: ContainersNotReady + status: "False" + type: Ready + - lastProbeTime: null + lastTransitionTime: "2023-06-08T11:50:59Z" + message: 'containers with unready status: [debian-container]' + reason: ContainersNotReady + status: "False" + type: ContainersReady + - lastProbeTime: null + lastTransitionTime: "2023-06-08T11:50:59Z" + status: "True" + type: PodScheduled + containerStatuses: + - containerID: docker://ead1d3e4beaaa9176daca99e55673a2176e0da51d9953d6a11d5786b730178ee + image: debian:latest + imageID: docker-pullable://debian@sha256:432f545c6ba13b79e2681f4cc4858788b0ab099fc1cca799cc0fae4687c69070 + lastState: + terminated: + containerID: docker://ead1d3e4beaaa9176daca99e55673a2176e0da51d9953d6a11d5786b730178ee + exitCode: 0 + finishedAt: "2023-06-08T11:51:19Z" + reason: Completed + startedAt: "2023-06-08T11:51:19Z" + name: debian-container + ready: false + restartCount: 1 + started: false + state: + waiting: + message: back-off 10s restarting failed container=debian-container pod=shared-storage_default(0c916935-8198-4d62-980e-193f3c3ec877) + reason: CrashLoopBackOff + - containerID: docker://afd6260e41afa0b149ebfd904162fb2f22bb037c18904eed599eb9ac1ce4faf0 + image: nginx:latest + imageID: docker-pullable://nginx@sha256:af296b188c7b7df99ba960ca614439c99cb7cf252ed7bbc23e90cfda59092305 + lastState: {} + name: nginx-container + ready: true + restartCount: 0 + started: true + state: + running: + startedAt: "2023-06-08T11:51:09Z" + hostIP: 192.168.49.2 + phase: Running + podIP: 10.244.0.3 + podIPs: + - ip: 10.244.0.3 + qosClass: BestEffort + startTime: "2023-06-08T11:50:59Z" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/nginx-deployment.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/nginx-deployment.yaml new file mode 100644 index 0000000000..5478ac1747 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/nginx-deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2 +kind: Deployment +metadata: + name: "test" + generation: 1 +spec: + progressDeadlineSeconds: 600 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: "test-dependent" + replicas: 1 + template: + metadata: + labels: + app: "test-dependent" + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-ds-resources-desired-update.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-ds-resources-desired-update.yaml new file mode 100644 index 0000000000..b8e330a19e --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-ds-resources-desired-update.yaml @@ -0,0 +1,28 @@ +# desired DaemonSet with Resources with an updated resource limit +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: "test" +spec: + selector: + matchLabels: + app: test-app + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + resources: + limits: + cpu: "4000m" + memory: "2Gi" + ephemeral-storage: "100G" + requests: + cpu: "1000m" + memory: "2Gi" + ephemeral-storage: "100G" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-ds-resources-desired.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-ds-resources-desired.yaml new file mode 100644 index 0000000000..9cfa95d06e --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-ds-resources-desired.yaml @@ -0,0 +1,28 @@ +# desired DaemonSet with Resources +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: "test" +spec: + selector: + matchLabels: + app: test-app + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + resources: + limits: + cpu: "2000m" + memory: "2Gi" + ephemeral-storage: "100G" + requests: + cpu: "1000m" + memory: "2Gi" + ephemeral-storage: "100G" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-ds-resources.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-ds-resources.yaml new file mode 100644 index 0000000000..f22730efd5 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-ds-resources.yaml @@ -0,0 +1,53 @@ +# actual DaemonSet with Resources +apiVersion: apps/v1 +kind: DaemonSet +metadata: + managedFields: + - manager: controller + operation: Apply + apiVersion: apps/v1 + time: '2024-10-24T19:15:25Z' + fieldsType: FieldsV1 + fieldsV1: + f:spec: + f:selector: { } + f:template: + f:metadata: + f:labels: + f:app: { } + f:spec: + f:containers: + k:{"name":"nginx"}: + .: { } + f:image: { } + f:name: { } + f:ports: + k:{"containerPort":80}: + .: { } + f:containerPort: { } + f:resources: { } + name: "test" + uid: 50913e35-e855-469f-bec6-3e8cd2607ab4 +spec: + selector: + matchLabels: + app: test-app + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + resources: + limits: + cpu: "2" + memory: "2Gi" + ephemeral-storage: "100G" + requests: + cpu: "1" + memory: "2Gi" + ephemeral-storage: "100G" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-rs-resources-desired-update.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-rs-resources-desired-update.yaml new file mode 100644 index 0000000000..6a4236c1ee --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-rs-resources-desired-update.yaml @@ -0,0 +1,29 @@ +# desired ReplicaSet with Resources with an updated resource limit +apiVersion: apps/v1 +kind: ReplicaSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + resources: + limits: + cpu: "4000m" + memory: "2Gi" + ephemeral-storage: "100G" + requests: + cpu: "1000m" + memory: "2Gi" + ephemeral-storage: "100G" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-rs-resources-desired.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-rs-resources-desired.yaml new file mode 100644 index 0000000000..95dcefecc5 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-rs-resources-desired.yaml @@ -0,0 +1,29 @@ +# desired ReplicaSet with Resources +apiVersion: apps/v1 +kind: ReplicaSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + resources: + limits: + cpu: "2000m" + memory: "2Gi" + ephemeral-storage: "100G" + requests: + cpu: "1000m" + memory: "2Gi" + ephemeral-storage: "100G" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-rs-resources.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-rs-resources.yaml new file mode 100644 index 0000000000..59a66b91f4 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-rs-resources.yaml @@ -0,0 +1,55 @@ +# actual ReplicaSet with Resources +apiVersion: apps/v1 +kind: ReplicaSet +metadata: + managedFields: + - manager: controller + operation: Apply + apiVersion: apps/v1 + time: '2024-10-24T19:15:25Z' + fieldsType: FieldsV1 + fieldsV1: + f:spec: + f:replicas: { } + f:selector: { } + f:template: + f:metadata: + f:labels: + f:app: { } + f:spec: + f:containers: + k:{"name":"nginx"}: + .: { } + f:image: { } + f:name: { } + f:ports: + k:{"containerPort":80}: + .: { } + f:containerPort: { } + f:resources: { } + name: "test" + uid: 50913e35-e855-469f-bec6-3e8cd2607ab4 +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + resources: + limits: + cpu: "2" + memory: "2Gi" + ephemeral-storage: "100G" + requests: + cpu: "1" + memory: "2Gi" + ephemeral-storage: "100G" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-resources-desired-update.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-resources-desired-update.yaml new file mode 100644 index 0000000000..721d2bfe51 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-resources-desired-update.yaml @@ -0,0 +1,30 @@ +# desired StatefulSet with Resources with an updated resource limit +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + resources: + limits: + cpu: "4000m" + memory: "2Gi" + ephemeral-storage: "100G" + requests: + cpu: "1000m" + memory: "2Gi" + ephemeral-storage: "100G" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-resources-desired.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-resources-desired.yaml new file mode 100644 index 0000000000..a23c1b1aae --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-resources-desired.yaml @@ -0,0 +1,30 @@ +# desired StatefulSet with Resources +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + resources: + limits: + cpu: "2000m" + memory: "2Gi" + ephemeral-storage: "100G" + requests: + cpu: "1000m" + memory: "2Gi" + ephemeral-storage: "100G" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-resources.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-resources.yaml new file mode 100644 index 0000000000..948035017a --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-resources.yaml @@ -0,0 +1,57 @@ +# actual StatefulSet with Resources +apiVersion: apps/v1 +kind: StatefulSet +metadata: + managedFields: + - manager: controller + operation: Apply + apiVersion: apps/v1 + time: '2024-10-24T19:15:25Z' + fieldsType: FieldsV1 + fieldsV1: + f:spec: + f:replicas: { } + f:selector: { } + f:serviceName: { } + f:template: + f:metadata: + f:labels: + f:app: { } + f:spec: + f:containers: + k:{"name":"nginx"}: + .: { } + f:image: { } + f:name: { } + f:ports: + k:{"containerPort":80}: + .: { } + f:containerPort: { } + f:resources: { } + name: "test" + uid: 50913e35-e855-469f-bec6-3e8cd2607ab4 +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + resources: + limits: + cpu: "2" + memory: "2Gi" + ephemeral-storage: "100G" + requests: + cpu: "1" + memory: "2Gi" + ephemeral-storage: "100G" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-add.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-add.yaml new file mode 100644 index 0000000000..289baa7f11 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-add.yaml @@ -0,0 +1,43 @@ +# desired StatefulSet with a VolumeClaimTemplate with an additional VolumeClaimTemplate +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + volumeMounts: + - name: persistent-storage + mountPath: /usr/share/nginx/html + volumeClaimTemplates: + - metadata: + name: persistent-storage + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: standard + - metadata: + name: persistent-storage-new + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: standard diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-update.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-update.yaml new file mode 100644 index 0000000000..c46d522c2a --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-update.yaml @@ -0,0 +1,34 @@ +# desired StatefulSet with a VolumeClaimTemplate with an updated VolumeClaimTemplate +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + volumeMounts: + - name: persistent-storage + mountPath: /usr/share/nginx/html + volumeClaimTemplates: + - metadata: + name: persistent-storage + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi + storageClassName: standard diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-status-mismatch.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-status-mismatch.yaml new file mode 100644 index 0000000000..df7f05790b --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-status-mismatch.yaml @@ -0,0 +1,36 @@ +# desired StatefulSet with a VolumeClaimTemplate with a mismatching spec.volumeClaimTemplates.spec.status +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + volumeMounts: + - name: persistent-storage + mountPath: /usr/share/nginx/html + volumeClaimTemplates: + - metadata: + name: persistent-storage + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: standard + status: + phase: Bound diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-status.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-status.yaml new file mode 100644 index 0000000000..79d9eebdb2 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-status.yaml @@ -0,0 +1,36 @@ +# desired StatefulSet with a VolumeClaimTemplate with a matching spec.volumeClaimTemplates.spec.status +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + volumeMounts: + - name: persistent-storage + mountPath: /usr/share/nginx/html + volumeClaimTemplates: + - metadata: + name: persistent-storage + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: standard + status: + phase: Pending diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-volumemode-mismatch.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-volumemode-mismatch.yaml new file mode 100644 index 0000000000..9b38361951 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-volumemode-mismatch.yaml @@ -0,0 +1,35 @@ +# desired StatefulSet with a VolumeClaimTemplate with a mismatching spec.volumeClaimTemplates.spec.volumeMode +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + volumeMounts: + - name: persistent-storage + mountPath: /usr/share/nginx/html + volumeClaimTemplates: + - metadata: + name: persistent-storage + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: standard + volumeMode: Block diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-volumemode.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-volumemode.yaml new file mode 100644 index 0000000000..03fa30eb8a --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-volumemode.yaml @@ -0,0 +1,35 @@ +# desired StatefulSet with a VolumeClaimTemplate with a matching spec.volumeClaimTemplates.spec.volumeMode +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + volumeMounts: + - name: persistent-storage + mountPath: /usr/share/nginx/html + volumeClaimTemplates: + - metadata: + name: persistent-storage + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: standard + volumeMode: Filesystem diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired.yaml new file mode 100644 index 0000000000..c44ef17062 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired.yaml @@ -0,0 +1,34 @@ +# desired StatefulSet with a VolumeClaimTemplate +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + volumeMounts: + - name: persistent-storage + mountPath: /usr/share/nginx/html + volumeClaimTemplates: + - metadata: + name: persistent-storage + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: standard diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates.yaml new file mode 100644 index 0000000000..4d8cf6789d --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates.yaml @@ -0,0 +1,69 @@ +# actual StatefulSet with a VolumeClaimTemplate +apiVersion: apps/v1 +kind: StatefulSet +metadata: + managedFields: + - manager: controller + operation: Apply + apiVersion: apps/v1 + time: '2024-10-24T19:15:25Z' + fieldsType: FieldsV1 + fieldsV1: + f:spec: + f:replicas: { } + f:selector: { } + f:serviceName: { } + f:template: + f:metadata: + f:labels: + f:app: { } + f:spec: + f:containers: + k:{"name":"nginx"}: + .: { } + f:image: { } + f:name: { } + f:ports: + k:{"containerPort":80}: + .: { } + f:containerPort: { } + f:volumeMounts: + k:{"mountPath":"/usr/share/nginx/html"}: + .: { } + f:mountPath: { } + f:name: { } + f:volumeClaimTemplates: { } + name: "test" + uid: 50913e35-e855-469f-bec6-3e8cd2607ab4 +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + volumeMounts: + - name: persistent-storage + mountPath: /usr/share/nginx/html + volumeClaimTemplates: + - metadata: + name: persistent-storage + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + storageClassName: standard + volumeMode: Filesystem + status: + phase: Pending diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-whole-complex-part-managed-desired.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-whole-complex-part-managed-desired.yaml new file mode 100644 index 0000000000..3baff88db0 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-whole-complex-part-managed-desired.yaml @@ -0,0 +1,24 @@ +kind: FlowSchema +metadata: + annotations: + apf.kubernetes.io/autoupdate-spec: "true" + name: probes +spec: + matchingPrecedence: 2 + priorityLevelConfiguration: + name: exempt + rules: + - nonResourceRules: + - nonResourceURLs: + - /healthz + - /readyz + - /livez + verbs: + - get + subjects: + - group: + name: system:unauthenticated + kind: Group + - group: + name: system:authenticated + kind: Group diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-whole-complex-part-managed.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-whole-complex-part-managed.yaml new file mode 100644 index 0000000000..9d9e20efbb --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-whole-complex-part-managed.yaml @@ -0,0 +1,44 @@ +kind: FlowSchema +metadata: + annotations: + apf.kubernetes.io/autoupdate-spec: "true" + creationTimestamp: "2023-06-08T11:18:25Z" + generation: 1 + managedFields: + - apiVersion: flowcontrol.apiserver.k8s.io/v1beta3 + fieldsType: FieldsV1 + fieldsV1: + f:metadata: + f:annotations: + .: {} + f:apf.kubernetes.io/autoupdate-spec: {} + f:spec: + f:matchingPrecedence: {} + f:priorityLevelConfiguration: + f:name: {} + f:rules: {} + manager: controller + operation: Apply + time: "2023-06-08T11:18:25Z" + name: probes + resourceVersion: "68" + uid: 50913e35-e855-469f-bec6-3e8cd2607ab4 +spec: + matchingPrecedence: 2 + priorityLevelConfiguration: + name: exempt + rules: + - nonResourceRules: + - nonResourceURLs: + - /healthz + - /readyz + - /livez + verbs: + - get + subjects: + - group: + name: system:unauthenticated + kind: Group + - group: + name: system:authenticated + kind: Group diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret-desired.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret-desired.yaml new file mode 100644 index 0000000000..29f9866592 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret-desired.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +metadata: + name: test1 + namespace: default +data: + key1: "dmFsMQ==" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret-with-finalizer-desired.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret-with-finalizer-desired.yaml new file mode 100644 index 0000000000..f0e64b1a60 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret-with-finalizer-desired.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + finalizers: + - test-finalizer + name: test1 + namespace: default +data: + key1: "dmFsMQ==" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret-with-finalizer.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret-with-finalizer.yaml new file mode 100644 index 0000000000..fa9ffc13a0 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret-with-finalizer.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +data: + key1: "dmFsMQ==" +kind: Secret +metadata: + creationTimestamp: "2023-06-07T11:08:34Z" + finalizers: + - test-finalizer + managedFields: + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + f:key1: {} + f:metadata: + f:finalizers: + .: {} + v:"test-finalizer": {} + manager: controller + operation: Apply + time: "2023-06-07T11:08:34Z" + name: test1 + namespace: default + resourceVersion: "400" + uid: 1d47f98f-ff1e-46d8-bbb5-6658ec488ae2 diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret.yaml new file mode 100644 index 0000000000..a6dc3b3c3e --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/secret.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +data: + key1: "dmFsMQ==" +kind: Secret +metadata: + creationTimestamp: "2023-06-07T11:08:34Z" + managedFields: + - apiVersion: v1 + fieldsType: FieldsV1 + fieldsV1: + f:data: + f:key1: {} + manager: controller + operation: Apply + time: "2023-06-07T11:08:34Z" + name: test1 + namespace: default + resourceVersion: "400" + uid: 1d47f98f-ff1e-46d8-bbb5-6658ec488ae2 diff --git a/operator-framework-core/src/test/resources/log4j2.xml b/operator-framework-core/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..2892dc78dc --- /dev/null +++ b/operator-framework-core/src/test/resources/log4j2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/operator-framework-junit5/pom.xml b/operator-framework-junit5/pom.xml new file mode 100644 index 0000000000..5aeeb92d45 --- /dev/null +++ b/operator-framework-junit5/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + + io.javaoperatorsdk + java-operator-sdk + 5.1.5-SNAPSHOT + + + operator-framework-junit-5 + Operator SDK - Framework - JUnit 5 extension + + + + io.javaoperatorsdk + operator-framework-core + ${project.version} + + + org.junit.jupiter + junit-jupiter-api + + + org.junit.jupiter + junit-jupiter-engine + + + org.assertj + assertj-core + + + org.awaitility + awaitility + + + org.mockito + mockito-core + test + + + + diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java new file mode 100644 index 0000000000..794bc11d9a --- /dev/null +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java @@ -0,0 +1,278 @@ +package io.javaoperatorsdk.operator.junit; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.*; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.utils.Utils; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; + +public abstract class AbstractOperatorExtension + implements HasKubernetesClient, + BeforeAllCallback, + BeforeEachCallback, + AfterAllCallback, + AfterEachCallback { + + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractOperatorExtension.class); + public static final int MAX_NAMESPACE_NAME_LENGTH = 63; + public static final int CRD_READY_WAIT = 2000; + public static final int DEFAULT_NAMESPACE_DELETE_TIMEOUT = 90; + + private final KubernetesClient kubernetesClient; + protected final List infrastructure; + protected Duration infrastructureTimeout; + protected final boolean oneNamespacePerClass; + protected final boolean preserveNamespaceOnError; + protected final boolean waitForNamespaceDeletion; + protected final int namespaceDeleteTimeout = DEFAULT_NAMESPACE_DELETE_TIMEOUT; + protected final Function namespaceNameSupplier; + protected final Function perClassNamespaceNameSupplier; + + protected String namespace; + + protected AbstractOperatorExtension( + List infrastructure, + Duration infrastructureTimeout, + boolean oneNamespacePerClass, + boolean preserveNamespaceOnError, + boolean waitForNamespaceDeletion, + KubernetesClient kubernetesClient, + Function namespaceNameSupplier, + Function perClassNamespaceNameSupplier) { + this.kubernetesClient = + kubernetesClient != null ? kubernetesClient : new KubernetesClientBuilder().build(); + this.infrastructure = infrastructure; + this.infrastructureTimeout = infrastructureTimeout; + this.oneNamespacePerClass = oneNamespacePerClass; + this.preserveNamespaceOnError = preserveNamespaceOnError; + this.waitForNamespaceDeletion = waitForNamespaceDeletion; + this.namespaceNameSupplier = namespaceNameSupplier; + this.perClassNamespaceNameSupplier = perClassNamespaceNameSupplier; + } + + @Override + public void beforeAll(ExtensionContext context) { + beforeAllImpl(context); + } + + @Override + public void beforeEach(ExtensionContext context) { + beforeEachImpl(context); + } + + @Override + public void afterAll(ExtensionContext context) { + afterAllImpl(context); + } + + @Override + public void afterEach(ExtensionContext context) { + afterEachImpl(context); + } + + @Override + public KubernetesClient getKubernetesClient() { + return kubernetesClient; + } + + public String getNamespace() { + return namespace; + } + + public + NonNamespaceOperation, Resource> resources(Class type) { + return kubernetesClient.resources(type).inNamespace(namespace); + } + + public T get(Class type, String name) { + return kubernetesClient.resources(type).inNamespace(namespace).withName(name).get(); + } + + public T create(T resource) { + return kubernetesClient.resource(resource).inNamespace(namespace).create(); + } + + public T serverSideApply(T resource) { + return kubernetesClient.resource(resource).inNamespace(namespace).serverSideApply(); + } + + public T replace(T resource) { + return kubernetesClient.resource(resource).inNamespace(namespace).replace(); + } + + public boolean delete(T resource) { + var res = kubernetesClient.resource(resource).inNamespace(namespace).delete(); + return res.size() == 1 && res.get(0).getCauses().isEmpty(); + } + + protected void beforeAllImpl(ExtensionContext context) { + if (oneNamespacePerClass) { + namespace = perClassNamespaceNameSupplier.apply(context); + before(context); + } + } + + protected void beforeEachImpl(ExtensionContext context) { + if (!oneNamespacePerClass) { + namespace = namespaceNameSupplier.apply(context); + before(context); + } + } + + protected void before(ExtensionContext context) { + LOGGER.info("Initializing integration test in namespace {}", namespace); + + kubernetesClient + .namespaces() + .resource( + new NamespaceBuilder() + .withMetadata(new ObjectMetaBuilder().withName(namespace).build()) + .build()) + .serverSideApply(); + + kubernetesClient.resourceList(infrastructure).serverSideApply(); + kubernetesClient + .resourceList(infrastructure) + .waitUntilReady(infrastructureTimeout.toMillis(), TimeUnit.MILLISECONDS); + } + + protected void afterAllImpl(ExtensionContext context) { + if (oneNamespacePerClass) { + after(context); + } + } + + protected void afterEachImpl(ExtensionContext context) { + if (!oneNamespacePerClass) { + after(context); + } + } + + protected void after(ExtensionContext context) { + if (namespace != null) { + if (preserveNamespaceOnError && context.getExecutionException().isPresent()) { + LOGGER.info("Preserving namespace {}", namespace); + } else { + kubernetesClient.resourceList(infrastructure).delete(); + deleteOperator(); + LOGGER.info("Deleting namespace {} and stopping operator", namespace); + kubernetesClient.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(() -> kubernetesClient.namespaces().withName(namespace).get() == null); + } + } + } + } + + protected void deleteOperator() { + // nothing to do by default: only needed if the operator is deployed to the cluster + } + + @SuppressWarnings("unchecked") + public abstract static class AbstractBuilder> { + protected final List infrastructure; + protected Duration infrastructureTimeout; + protected boolean preserveNamespaceOnError; + protected boolean waitForNamespaceDeletion; + protected boolean oneNamespacePerClass; + protected int namespaceDeleteTimeout; + protected Consumer configurationServiceOverrider; + protected Function namespaceNameSupplier = + new DefaultNamespaceNameSupplier(); + protected Function perClassNamespaceNameSupplier = + new DefaultPerClassNamespaceNameSupplier(); + + protected AbstractBuilder() { + this.infrastructure = new ArrayList<>(); + this.infrastructureTimeout = Duration.ofMinutes(1); + + this.preserveNamespaceOnError = + Utils.getSystemPropertyOrEnvVar("josdk.it.preserveNamespaceOnError", false); + + this.waitForNamespaceDeletion = + Utils.getSystemPropertyOrEnvVar("josdk.it.waitForNamespaceDeletion", true); + + this.oneNamespacePerClass = + Utils.getSystemPropertyOrEnvVar("josdk.it.oneNamespacePerClass", false); + + this.namespaceDeleteTimeout = + Utils.getSystemPropertyOrEnvVar( + "josdk.it.namespaceDeleteTimeout", DEFAULT_NAMESPACE_DELETE_TIMEOUT); + } + + public T preserveNamespaceOnError(boolean value) { + this.preserveNamespaceOnError = value; + return (T) this; + } + + public T waitForNamespaceDeletion(boolean value) { + this.waitForNamespaceDeletion = value; + return (T) this; + } + + public T oneNamespacePerClass(boolean value) { + this.oneNamespacePerClass = value; + return (T) this; + } + + public T withConfigurationService(Consumer overrider) { + configurationServiceOverrider = overrider; + return (T) this; + } + + public T withInfrastructureTimeout(Duration value) { + infrastructureTimeout = value; + return (T) this; + } + + public T withInfrastructure(List hm) { + infrastructure.addAll(hm); + return (T) this; + } + + public T withInfrastructure(HasMetadata... hms) { + infrastructure.addAll(Arrays.asList(hms)); + return (T) this; + } + + public T withNamespaceDeleteTimeout(int timeout) { + this.namespaceDeleteTimeout = timeout; + return (T) this; + } + + public AbstractBuilder withNamespaceNameSupplier( + Function namespaceNameSupplier) { + this.namespaceNameSupplier = namespaceNameSupplier; + return this; + } + + public AbstractBuilder withPerClassNamespaceNameSupplier( + Function perClassNamespaceNameSupplier) { + this.perClassNamespaceNameSupplier = perClassNamespaceNameSupplier; + return this; + } + } +} diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java new file mode 100644 index 0000000000..3fc49d4575 --- /dev/null +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java @@ -0,0 +1,167 @@ +package io.javaoperatorsdk.operator.junit; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.rbac.ClusterRoleBinding; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; + +public class ClusterDeployedOperatorExtension extends AbstractOperatorExtension { + + private static final Logger LOGGER = + LoggerFactory.getLogger(ClusterDeployedOperatorExtension.class); + + private final List operatorDeployment; + private final Duration operatorDeploymentTimeout; + + private ClusterDeployedOperatorExtension( + List operatorDeployment, + Duration operatorDeploymentTimeout, + List infrastructure, + Duration infrastructureTimeout, + boolean preserveNamespaceOnError, + boolean waitForNamespaceDeletion, + boolean oneNamespacePerClass, + KubernetesClient kubernetesClient, + Function namespaceNameSupplier, + Function perClassNamespaceNameSupplier) { + super( + infrastructure, + infrastructureTimeout, + oneNamespacePerClass, + preserveNamespaceOnError, + waitForNamespaceDeletion, + kubernetesClient, + namespaceNameSupplier, + perClassNamespaceNameSupplier); + this.operatorDeployment = operatorDeployment; + this.operatorDeploymentTimeout = operatorDeploymentTimeout; + } + + /** + * Creates a {@link Builder} to set up an {@link ClusterDeployedOperatorExtension} instance. + * + * @return the builder. + */ + public static Builder builder() { + return new Builder(); + } + + protected void before(ExtensionContext context) { + super.before(context); + + final var crdPath = "./target/classes/META-INF/fabric8/"; + final var crdSuffix = "-v1.yml"; + + final var kubernetesClient = getKubernetesClient(); + for (var crdFile : + Objects.requireNonNull( + new File(crdPath).listFiles((ignored, name) -> name.endsWith(crdSuffix)))) { + try (InputStream is = new FileInputStream(crdFile)) { + final var crd = kubernetesClient.load(is); + crd.createOrReplace(); + Thread.sleep(CRD_READY_WAIT); // readiness is not applicable for CRD, just wait a little + LOGGER.debug("Applied CRD with name: {}", crd.get().get(0).getMetadata().getName()); + } catch (InterruptedException ex) { + LOGGER.error("Interrupted.", ex); + Thread.currentThread().interrupt(); + } catch (Exception ex) { + throw new IllegalStateException("Cannot apply CRD yaml: " + crdFile.getAbsolutePath(), ex); + } + } + + LOGGER.debug("Deploying the operator into Kubernetes. Target namespace: {}", namespace); + operatorDeployment.forEach( + hm -> { + hm.getMetadata().setNamespace(namespace); + if (hm.getKind().toLowerCase(Locale.ROOT).equals("clusterrolebinding")) { + var crb = (ClusterRoleBinding) hm; + for (var subject : crb.getSubjects()) { + subject.setNamespace(namespace); + } + } + }); + + kubernetesClient.resourceList(operatorDeployment).inNamespace(namespace).createOrReplace(); + kubernetesClient + .resourceList(operatorDeployment) + .waitUntilReady(operatorDeploymentTimeout.toMillis(), TimeUnit.MILLISECONDS); + LOGGER.debug("Operator resources deployed."); + } + + @Override + protected void deleteOperator() { + getKubernetesClient().resourceList(operatorDeployment).inNamespace(namespace).delete(); + } + + public static class Builder extends AbstractBuilder { + private final List operatorDeployment; + private Duration deploymentTimeout; + private KubernetesClient kubernetesClient; + + protected Builder() { + super(); + this.operatorDeployment = new ArrayList<>(); + this.deploymentTimeout = Duration.ofMinutes(1); + } + + @SuppressWarnings("unused") + public Builder withDeploymentTimeout(Duration value) { + deploymentTimeout = value; + return this; + } + + public Builder withOperatorDeployment( + List hm, Consumer> modifications) { + modifications.accept(hm); + operatorDeployment.addAll(hm); + return this; + } + + public Builder withOperatorDeployment(List hm) { + operatorDeployment.addAll(hm); + return this; + } + + @SuppressWarnings("unused") + public Builder withOperatorDeployment(HasMetadata... hms) { + operatorDeployment.addAll(Arrays.asList(hms)); + return this; + } + + public Builder withKubernetesClient(KubernetesClient kubernetesClient) { + this.kubernetesClient = kubernetesClient; + return this; + } + + public ClusterDeployedOperatorExtension build() { + return new ClusterDeployedOperatorExtension( + operatorDeployment, + deploymentTimeout, + infrastructure, + infrastructureTimeout, + preserveNamespaceOnError, + waitForNamespaceDeletion, + oneNamespacePerClass, + kubernetesClient != null ? kubernetesClient : new KubernetesClientBuilder().build(), + namespaceNameSupplier, + perClassNamespaceNameSupplier); + } + } +} diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java new file mode 100644 index 0000000000..7e496ad2e2 --- /dev/null +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplier.java @@ -0,0 +1,55 @@ +package io.javaoperatorsdk.operator.junit; + +import java.util.Locale; +import java.util.UUID; +import java.util.function.Function; + +import org.junit.jupiter.api.extension.ExtensionContext; + +import io.fabric8.kubernetes.client.utils.KubernetesResourceUtil; + +import static io.javaoperatorsdk.operator.junit.AbstractOperatorExtension.MAX_NAMESPACE_NAME_LENGTH; + +public class DefaultNamespaceNameSupplier implements Function { + + public static final int RANDOM_SUFFIX_LENGTH = 5; + public static final int DELIMITERS_LENGTH = 2; + + public static final int MAX_NAME_LENGTH_TOGETHER = + MAX_NAMESPACE_NAME_LENGTH - DELIMITERS_LENGTH - RANDOM_SUFFIX_LENGTH; + public static final int PART_RESERVED_NAME_LENGTH = MAX_NAME_LENGTH_TOGETHER / 2; + + public static final String DELIMITER = "-"; + + @Override + public String apply(ExtensionContext context) { + String classPart = context.getRequiredTestClass().getSimpleName(); + String methodPart = context.getRequiredTestMethod().getName(); + if (classPart.length() + methodPart.length() + DELIMITERS_LENGTH + RANDOM_SUFFIX_LENGTH + > MAX_NAMESPACE_NAME_LENGTH) { + if (classPart.length() > PART_RESERVED_NAME_LENGTH) { + int classPartMaxLength = + methodPart.length() > PART_RESERVED_NAME_LENGTH + ? PART_RESERVED_NAME_LENGTH + : MAX_NAME_LENGTH_TOGETHER - methodPart.length(); + classPart = classPart.substring(0, Math.min(classPartMaxLength, classPart.length())); + } + if (methodPart.length() > PART_RESERVED_NAME_LENGTH) { + int methodPartMaxLength = + classPart.length() > PART_RESERVED_NAME_LENGTH + ? PART_RESERVED_NAME_LENGTH + : MAX_NAME_LENGTH_TOGETHER - classPart.length(); + methodPart = methodPart.substring(0, Math.min(methodPartMaxLength, methodPart.length())); + } + } + + String namespace = + classPart + + DELIMITER + + methodPart + + DELIMITER + + UUID.randomUUID().toString().substring(0, RANDOM_SUFFIX_LENGTH); + namespace = KubernetesResourceUtil.sanitizeName(namespace).toLowerCase(Locale.US); + return namespace; + } +} diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java new file mode 100644 index 0000000000..48f0ae9660 --- /dev/null +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplier.java @@ -0,0 +1,33 @@ +package io.javaoperatorsdk.operator.junit; + +import java.util.Locale; +import java.util.UUID; +import java.util.function.Function; + +import org.junit.jupiter.api.extension.ExtensionContext; + +import io.fabric8.kubernetes.client.utils.KubernetesResourceUtil; + +import static io.javaoperatorsdk.operator.junit.AbstractOperatorExtension.MAX_NAMESPACE_NAME_LENGTH; +import static io.javaoperatorsdk.operator.junit.DefaultNamespaceNameSupplier.DELIMITER; +import static io.javaoperatorsdk.operator.junit.DefaultNamespaceNameSupplier.RANDOM_SUFFIX_LENGTH; + +public class DefaultPerClassNamespaceNameSupplier implements Function { + + public static final int MAX_CLASS_NAME_LENGTH = + MAX_NAMESPACE_NAME_LENGTH - RANDOM_SUFFIX_LENGTH - 1; + + @Override + public String apply(ExtensionContext context) { + String className = context.getRequiredTestClass().getSimpleName(); + String namespace = + className.length() > MAX_CLASS_NAME_LENGTH + ? className.substring(0, MAX_CLASS_NAME_LENGTH) + : className; + namespace += DELIMITER; + namespace += UUID.randomUUID().toString().substring(0, RANDOM_SUFFIX_LENGTH); + namespace = KubernetesResourceUtil.sanitizeName(namespace).toLowerCase(Locale.US); + namespace = namespace.substring(0, Math.min(namespace.length(), MAX_NAMESPACE_NAME_LENGTH)); + return namespace; + } +} diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java new file mode 100644 index 0000000000..d93032333f --- /dev/null +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java @@ -0,0 +1,7 @@ +package io.javaoperatorsdk.operator.junit; + +import io.fabric8.kubernetes.client.KubernetesClient; + +public interface HasKubernetesClient { + KubernetesClient getKubernetesClient(); +} diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java new file mode 100644 index 0000000000..5ce0ace80b --- /dev/null +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/InClusterCurl.java @@ -0,0 +1,68 @@ +package io.javaoperatorsdk.operator.junit; + +import java.util.UUID; + +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.extended.run.RunConfigBuilder; +import io.fabric8.kubernetes.client.utils.KubernetesResourceUtil; + +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.awaitility.Awaitility.await; + +public class InClusterCurl { + + private final KubernetesClient client; + private final String namespace; + + public InClusterCurl(https://github.com/KubernetesClient%20client,%20String%20namespace) { + this.client = client; + this.namespace = namespace; + } + + public String checkUrl(String url) { + return checkUrl("-s", "-o", "/dev/null", "-w", "%{http_code}", url); + } + + public String checkUrl(String... args) { + String podName = KubernetesResourceUtil.sanitizeName("curl-" + UUID.randomUUID()); + try { + Pod curlPod = + client + .run() + .inNamespace(namespace) + .withRunConfig( + new RunConfigBuilder() + .withArgs(args) + .withName(podName) + .withImage("curlimages/curl:7.78.0") + .withRestartPolicy("Never") + .build()) + .done(); + await("wait-for-curl-pod-run") + .atMost(2, MINUTES) + .until( + () -> { + String phase = + client + .pods() + .inNamespace(namespace) + .withName(podName) + .get() + .getStatus() + .getPhase(); + return phase.equals("Succeeded") || phase.equals("Failed"); + }); + + String curlOutput = + client.pods().inNamespace(namespace).withName(curlPod.getMetadata().getName()).getLog(); + + return curlOutput; + } finally { + client.pods().inNamespace(namespace).withName(podName).delete(); + await("wait-for-curl-pod-stop") + .atMost(1, MINUTES) + .until(() -> client.pods().inNamespace(namespace).withName(podName).get() == null); + } + } +} diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java new file mode 100644 index 0000000000..54cb57544d --- /dev/null +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java @@ -0,0 +1,519 @@ +package io.javaoperatorsdk.operator.junit; + +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.fabric8.kubernetes.client.CustomResource; +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.RegisteredController; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.processing.retry.Retry; + +import static io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override; + +@SuppressWarnings("rawtypes") +public class LocallyRunOperatorExtension extends AbstractOperatorExtension { + + private static final Logger LOGGER = LoggerFactory.getLogger(LocallyRunOperatorExtension.class); + private static final int CRD_DELETE_TIMEOUT = 5000; + private static final Set appliedCRDs = new HashSet<>(); + private static final boolean deleteCRDs = + Boolean.parseBoolean(System.getProperty("testsuite.deleteCRDs", "true")); + + private final Operator operator; + private final List reconcilers; + private final List portForwards; + private final List localPortForwards; + private final List> additionalCustomResourceDefinitions; + private final Map registeredControllers; + private final Map crdMappings; + private final Consumer beforeStartHook; + + private LocallyRunOperatorExtension( + List reconcilers, + List infrastructure, + List portForwards, + List> additionalCustomResourceDefinitions, + Duration infrastructureTimeout, + boolean preserveNamespaceOnError, + boolean waitForNamespaceDeletion, + boolean oneNamespacePerClass, + KubernetesClient kubernetesClient, + Consumer configurationServiceOverrider, + Function namespaceNameSupplier, + Function perClassNamespaceNameSupplier, + List additionalCrds, + Consumer beforeStartHook) { + super( + infrastructure, + infrastructureTimeout, + oneNamespacePerClass, + preserveNamespaceOnError, + waitForNamespaceDeletion, + kubernetesClient, + namespaceNameSupplier, + perClassNamespaceNameSupplier); + this.reconcilers = reconcilers; + this.portForwards = portForwards; + this.localPortForwards = new ArrayList<>(portForwards.size()); + this.additionalCustomResourceDefinitions = additionalCustomResourceDefinitions; + this.beforeStartHook = beforeStartHook; + configurationServiceOverrider = + configurationServiceOverrider != null + ? configurationServiceOverrider.andThen( + overrider -> overrider.withKubernetesClient(kubernetesClient)) + : overrider -> overrider.withKubernetesClient(kubernetesClient); + this.operator = new Operator(configurationServiceOverrider); + this.registeredControllers = new HashMap<>(); + crdMappings = getAdditionalCRDsFromFiles(additionalCrds, getKubernetesClient()); + } + + static Map getAdditionalCRDsFromFiles( + Iterable additionalCrds, KubernetesClient client) { + Map crdMappings = new HashMap<>(); + additionalCrds.forEach( + p -> { + try (InputStream is = new FileInputStream(p)) { + client.load(is).items().stream() + // only consider CRDs to avoid applying random resources to the cluster + .filter(CustomResourceDefinition.class::isInstance) + .map(CustomResourceDefinition.class::cast) + .forEach(crd -> crdMappings.put(crd.getMetadata().getName(), p)); + } catch (Exception e) { + throw new RuntimeException("Couldn't load CRD at " + p, e); + } + }); + return crdMappings; + } + + /** + * Creates a {@link Builder} to set up an {@link LocallyRunOperatorExtension} instance. + * + * @return the builder. + */ + public static Builder builder() { + return new Builder(); + } + + public static void applyCrd(Class resourceClass, KubernetesClient client) { + applyCrd(ReconcilerUtils.getResourceTypeName(resourceClass), client); + } + + /** + * Applies the CRD associated with the specified resource name to the cluster. Note that the CRD + * is assumed to have been generated in this case from the Java classes and is therefore expected + * to be found in the standard location with the default name for such CRDs and assumes a v1 + * version of the CRD spec is used. This means that, provided a given {@code resourceTypeName}, + * the associated CRD is expected to be found at {@code META-INF/fabric8/resourceTypeName-v1.yml} + * in the project's classpath. + * + * @param resourceTypeName the standard resource name for CRDs i.e. {@code plural.group} + * @param client the kubernetes client to use to connect to the cluster + */ + public static void applyCrd(String resourceTypeName, KubernetesClient client) { + String path = "/META-INF/fabric8/" + resourceTypeName + "-v1.yml"; + try (InputStream is = LocallyRunOperatorExtension.class.getResourceAsStream(path)) { + if (is == null) { + throw new IllegalStateException("Cannot find CRD at " + path); + } + var crdString = new String(is.readAllBytes(), StandardCharsets.UTF_8); + applyCrd(crdString, path, client); + } catch (IOException e) { + throw new IllegalStateException("Cannot apply CRD yaml: " + path, e); + } + } + + private static void applyCrd(String crdString, String path, KubernetesClient client) { + try { + LOGGER.debug("Applying CRD: {}", crdString); + final var crd = client.load(new ByteArrayInputStream(crdString.getBytes())); + crd.serverSideApply(); + appliedCRDs.add(new AppliedCRD(crdString, path)); + Thread.sleep(CRD_READY_WAIT); // readiness is not applicable for CRD, just wait a little + LOGGER.debug("Applied CRD with path: {}", path); + } catch (InterruptedException ex) { + LOGGER.error("Interrupted.", ex); + Thread.currentThread().interrupt(); + } catch (Exception ex) { + throw new IllegalStateException("Cannot apply CRD yaml: " + path, ex); + } + } + + /** + * Applies the CRD associated with the specified custom resource, first checking if a CRD has been + * manually specified using {@link Builder#withAdditionalCRD}, otherwise assuming that its CRD + * should be found in the standard location as explained in {@link + * LocallyRunOperatorExtension#applyCrd(String, KubernetesClient)} + * + * @param crClass the custom resource class for which we want to apply the CRD + */ + public void applyCrd(Class crClass) { + applyCrd(ReconcilerUtils.getResourceTypeName(crClass)); + } + + /** + * Applies the CRD associated with the specified resource type name, first checking if a CRD has + * been manually specified using {@link Builder#withAdditionalCRD}, otherwise assuming that its + * CRD should be found in the standard location as explained in {@link + * LocallyRunOperatorExtension#applyCrd(String, KubernetesClient)} + * + * @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)} + */ + public void applyCrd(String resourceTypeName) { + // first attempt to use a manually defined CRD + final var pathAsString = crdMappings.get(resourceTypeName); + if (pathAsString != null) { + final var path = Path.of(pathAsString); + try { + applyCrd(Files.readString(path), pathAsString, getKubernetesClient()); + } catch (IOException e) { + throw new IllegalStateException("Cannot open CRD file at " + path.toAbsolutePath(), e); + } + crdMappings.remove(resourceTypeName); + } else { + // if no manually defined CRD matches the resource type, apply the generated one + applyCrd(resourceTypeName, getKubernetesClient()); + } + } + + private Stream reconcilers() { + return reconcilers.stream().map(reconcilerSpec -> reconcilerSpec.reconciler); + } + + public List getReconcilers() { + return reconcilers().toList(); + } + + public Reconciler getFirstReconciler() { + return reconcilers().findFirst().orElseThrow(); + } + + public T getReconcilerOfType(Class type) { + return reconcilers() + .filter(type::isInstance) + .map(type::cast) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("Unable to find a reconciler of type: " + type)); + } + + public RegisteredController getRegisteredControllerForReconcile( + Class type) { + return registeredControllers.get(getReconcilerOfType(type)); + } + + public Operator getOperator() { + return operator; + } + + @SuppressWarnings("unchecked") + @Override + protected void before(ExtensionContext context) { + super.before(context); + + final var kubernetesClient = getKubernetesClient(); + + for (var ref : portForwards) { + String podName = + kubernetesClient + .pods() + .inNamespace(ref.getNamespace()) + .withLabel(ref.getLabelKey(), ref.getLabelValue()) + .list() + .getItems() + .get(0) + .getMetadata() + .getName(); + + localPortForwards.add( + kubernetesClient + .pods() + .inNamespace(ref.getNamespace()) + .withName(podName) + .portForward(ref.getPort(), ref.getLocalPort())); + } + + additionalCustomResourceDefinitions.forEach(this::applyCrd); + for (var ref : reconcilers) { + final var config = operator.getConfigurationService().getConfigurationFor(ref.reconciler); + final var oconfig = override(config); + + final var resourceClass = config.getResourceClass(); + if (Namespaced.class.isAssignableFrom(resourceClass)) { + oconfig.settingNamespace(namespace); + } + + if (ref.retry != null) { + oconfig.withRetry(ref.retry); + } + if (ref.controllerConfigurationOverrider != null) { + ref.controllerConfigurationOverrider.accept(oconfig); + } + + final var resourceTypeName = ReconcilerUtils.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); + } + + // apply yet unapplied CRDs + var registeredController = this.operator.register(ref.reconciler, oconfig.build()); + registeredControllers.put(ref.reconciler, registeredController); + } + crdMappings.forEach( + (crdName, path) -> { + final String crdString; + try { + crdString = Files.readString(Path.of(path)); + } catch (IOException e) { + throw new IllegalArgumentException("Couldn't read CRD located at " + path, e); + } + applyCrd(crdString, path, getKubernetesClient()); + }); + crdMappings.clear(); + + if (beforeStartHook != null) { + beforeStartHook.accept(this); + } + + LOGGER.debug("Starting the operator locally"); + this.operator.start(); + } + + @Override + protected void after(ExtensionContext context) { + super.after(context); + + var kubernetesClient = getKubernetesClient(); + + var iterator = appliedCRDs.iterator(); + while (iterator.hasNext()) { + deleteCrd(iterator.next(), kubernetesClient); + iterator.remove(); + } + + kubernetesClient.close(); + + try { + this.operator.stop(); + } catch (Exception e) { + // ignored + } + + for (var ref : localPortForwards) { + try { + ref.close(); + } catch (Exception e) { + // ignored + } + } + localPortForwards.clear(); + } + + private void deleteCrd(AppliedCRD appliedCRD, KubernetesClient client) { + if (!deleteCRDs) { + LOGGER.debug("Skipping deleting CRD because of configuration: {}", appliedCRD); + return; + } + try { + LOGGER.debug("Deleting CRD: {}", appliedCRD.crdString); + final var crd = client.load(new ByteArrayInputStream(appliedCRD.crdString.getBytes())); + crd.withTimeoutInMillis(CRD_DELETE_TIMEOUT).delete(); + LOGGER.debug("Deleted CRD with path: {}", appliedCRD.path); + } catch (Exception ex) { + LOGGER.warn( + "Cannot delete CRD yaml: {}. You might need to delete it manually.", appliedCRD.path, ex); + } + } + + private record AppliedCRD(String crdString, String path) {} + + @SuppressWarnings("rawtypes") + public static class Builder extends AbstractBuilder { + private final List reconcilers; + private final List portForwards; + private final List> additionalCustomResourceDefinitions; + private final List additionalCRDs = new ArrayList<>(); + private Consumer beforeStartHook; + private KubernetesClient kubernetesClient; + + protected Builder() { + super(); + this.reconcilers = new ArrayList<>(); + this.portForwards = new ArrayList<>(); + this.additionalCustomResourceDefinitions = new ArrayList<>(); + } + + public Builder withReconciler( + Reconciler value, Consumer configurationOverrider) { + return withReconciler(value, null, configurationOverrider); + } + + public Builder withReconciler( + Reconciler value, + Retry retry, + Consumer configurationOverrider) { + reconcilers.add(new ReconcilerSpec(value, retry, configurationOverrider)); + return this; + } + + @SuppressWarnings("rawtypes") + public Builder withReconciler(Reconciler value) { + reconcilers.add(new ReconcilerSpec(value, null)); + return this; + } + + @SuppressWarnings("rawtypes") + public Builder withReconciler(Reconciler value, Retry retry) { + reconcilers.add(new ReconcilerSpec(value, retry)); + return this; + } + + @SuppressWarnings("rawtypes") + public Builder withReconciler(Class value) { + try { + reconcilers.add(new ReconcilerSpec(value.getConstructor().newInstance(), null)); + } catch (Exception e) { + throw new RuntimeException(e); + } + return this; + } + + public Builder withPortForward( + String namespace, String labelKey, String labelValue, int port, int localPort) { + portForwards.add(new PortForwardSpec(namespace, labelKey, labelValue, port, localPort)); + return this; + } + + public Builder withKubernetesClient(KubernetesClient kubernetesClient) { + this.kubernetesClient = kubernetesClient; + return this; + } + + public Builder withAdditionalCustomResourceDefinition( + Class customResource) { + additionalCustomResourceDefinitions.add(customResource); + return this; + } + + public Builder withAdditionalCRD(String... paths) { + if (paths != null) { + additionalCRDs.addAll(List.of(paths)); + } + return this; + } + + /** + * Used to initialize resources when the namespace is generated but the operator is not started + * yet. + */ + public Builder withBeforeStartHook(Consumer beforeStartHook) { + this.beforeStartHook = beforeStartHook; + return this; + } + + public LocallyRunOperatorExtension build() { + return new LocallyRunOperatorExtension( + reconcilers, + infrastructure, + portForwards, + additionalCustomResourceDefinitions, + infrastructureTimeout, + preserveNamespaceOnError, + waitForNamespaceDeletion, + oneNamespacePerClass, + kubernetesClient, + configurationServiceOverrider, + namespaceNameSupplier, + perClassNamespaceNameSupplier, + additionalCRDs, + beforeStartHook); + } + } + + private static class PortForwardSpec { + final String namespace; + final String labelKey; + final String labelValue; + final int port; + final int localPort; + + public PortForwardSpec( + String namespace, String labelKey, String labelValue, int port, int localPort) { + this.namespace = namespace; + this.labelKey = labelKey; + this.labelValue = labelValue; + this.port = port; + this.localPort = localPort; + } + + public String getNamespace() { + return namespace; + } + + public String getLabelKey() { + return labelKey; + } + + public String getLabelValue() { + return labelValue; + } + + public int getPort() { + return port; + } + + public int getLocalPort() { + return localPort; + } + } + + @SuppressWarnings("rawtypes") + private static class ReconcilerSpec { + final Reconciler reconciler; + final Retry retry; + final Consumer controllerConfigurationOverrider; + + public ReconcilerSpec(Reconciler reconciler, Retry retry) { + this(reconciler, retry, null); + } + + public ReconcilerSpec( + Reconciler reconciler, + Retry retry, + Consumer controllerConfigurationOverrider) { + this.reconciler = reconciler; + this.retry = retry; + this.controllerConfigurationOverrider = controllerConfigurationOverrider; + } + } +} diff --git a/operator-framework-junit5/src/test/crd/test.crd b/operator-framework-junit5/src/test/crd/test.crd new file mode 100644 index 0000000000..0d509f04ee --- /dev/null +++ b/operator-framework-junit5/src/test/crd/test.crd @@ -0,0 +1,21 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: externals.crd.example +spec: + group: crd.example + names: + kind: External + singular: external + plural: externals + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + foo: + type: "string" + type: "object" + served: true + storage: true diff --git a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java b/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java new file mode 100644 index 0000000000..18f021c24c --- /dev/null +++ b/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultNamespaceNameSupplierTest.java @@ -0,0 +1,55 @@ +package io.javaoperatorsdk.operator.junit; + +import org.junit.jupiter.api.Test; + +import static io.javaoperatorsdk.operator.junit.AbstractOperatorExtension.MAX_NAMESPACE_NAME_LENGTH; +import static io.javaoperatorsdk.operator.junit.DefaultNamespaceNameSupplier.*; +import static io.javaoperatorsdk.operator.junit.NamespaceNamingTestUtils.*; +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultNamespaceNameSupplierTest { + + DefaultNamespaceNameSupplier supplier = new DefaultNamespaceNameSupplier(); + + @Test + void trivialCase() { + String ns = supplier.apply(mockExtensionContext(SHORT_CLASS_NAME, SHORT_METHOD_NAME)); + + assertThat(ns).startsWith(SHORT_CLASS_NAME + DELIMITER + SHORT_METHOD_NAME + DELIMITER); + shortEnoughAndEndsWithRandomString(ns); + } + + @Test + void classPartLongerCase() { + String ns = supplier.apply(mockExtensionContext(LONG_CLASS_NAME, SHORT_METHOD_NAME)); + + assertThat(ns).startsWith(LONG_CLASS_NAME + DELIMITER + SHORT_METHOD_NAME + DELIMITER); + shortEnoughAndEndsWithRandomString(ns); + } + + @Test + void methodPartLonger() { + String ns = supplier.apply(mockExtensionContext(SHORT_CLASS_NAME, LONG_METHOD_NAME)); + + assertThat(ns).startsWith(SHORT_CLASS_NAME + DELIMITER + LONG_METHOD_NAME + DELIMITER); + shortEnoughAndEndsWithRandomString(ns); + } + + @Test + void methodPartAndClassPartLonger() { + String ns = supplier.apply(mockExtensionContext(LONG_CLASS_NAME, LONG_METHOD_NAME)); + + assertThat(ns) + .startsWith( + LONG_CLASS_NAME.substring(0, PART_RESERVED_NAME_LENGTH) + + DELIMITER + + LONG_METHOD_NAME.substring(0, PART_RESERVED_NAME_LENGTH) + + DELIMITER); + shortEnoughAndEndsWithRandomString(ns); + } + + private static void shortEnoughAndEndsWithRandomString(String ns) { + assertThat(ns.length()).isLessThanOrEqualTo(MAX_NAMESPACE_NAME_LENGTH); + assertThat(ns.split("-")[2]).hasSize(RANDOM_SUFFIX_LENGTH); + } +} diff --git a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java b/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java new file mode 100644 index 0000000000..5e46b15551 --- /dev/null +++ b/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/DefaultPerClassNamespaceNameSupplierTest.java @@ -0,0 +1,43 @@ +package io.javaoperatorsdk.operator.junit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; + +import static io.javaoperatorsdk.operator.junit.AbstractOperatorExtension.MAX_NAMESPACE_NAME_LENGTH; +import static io.javaoperatorsdk.operator.junit.DefaultNamespaceNameSupplier.DELIMITER; +import static io.javaoperatorsdk.operator.junit.DefaultNamespaceNameSupplier.RANDOM_SUFFIX_LENGTH; +import static io.javaoperatorsdk.operator.junit.DefaultPerClassNamespaceNameSupplier.MAX_CLASS_NAME_LENGTH; +import static io.javaoperatorsdk.operator.junit.NamespaceNamingTestUtils.SHORT_CLASS_NAME; +import static io.javaoperatorsdk.operator.junit.NamespaceNamingTestUtils.VERY_LONG_CLASS_NAME; +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultPerClassNamespaceNameSupplierTest { + + DefaultPerClassNamespaceNameSupplier supplier = new DefaultPerClassNamespaceNameSupplier(); + + @Test + void shortClassCase() { + var ns = supplier.apply(mockExtensionContext(SHORT_CLASS_NAME)); + + assertThat(ns).startsWith(SHORT_CLASS_NAME + DELIMITER); + shortEnoughAndEndsWithRandomString(ns); + } + + @Test + void longClassCase() { + var ns = supplier.apply(mockExtensionContext(VERY_LONG_CLASS_NAME)); + + assertThat(ns).startsWith(VERY_LONG_CLASS_NAME.substring(0, MAX_CLASS_NAME_LENGTH) + DELIMITER); + shortEnoughAndEndsWithRandomString(ns); + assertThat(ns).hasSize(MAX_NAMESPACE_NAME_LENGTH); + } + + public static ExtensionContext mockExtensionContext(String className) { + return NamespaceNamingTestUtils.mockExtensionContext(className, null); + } + + private static void shortEnoughAndEndsWithRandomString(String ns) { + assertThat(ns.length()).isLessThanOrEqualTo(MAX_NAMESPACE_NAME_LENGTH); + assertThat(ns.split("-")[1]).hasSize(RANDOM_SUFFIX_LENGTH); + } +} 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 new file mode 100644 index 0000000000..04ac7a91ae --- /dev/null +++ b/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtensionTest.java @@ -0,0 +1,27 @@ +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")); + } +} diff --git a/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java b/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java new file mode 100644 index 0000000000..72a6c66883 --- /dev/null +++ b/operator-framework-junit5/src/test/java/io/javaoperatorsdk/operator/junit/NamespaceNamingTestUtils.java @@ -0,0 +1,48 @@ +package io.javaoperatorsdk.operator.junit; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.extension.ExtensionContext; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class NamespaceNamingTestUtils { + + public static final String SHORT_CLASS_NAME = Method.class.getSimpleName().toLowerCase(); + public static final String SHORT_METHOD_NAME = "short"; + public static final String LONG_METHOD_NAME = "longmethodnametotestifistruncatedcorrectly"; + public static final String LONG_CLASS_NAME = + VeryLongClassNameForSakeOfThisTestIfItWorks.class.getSimpleName().toLowerCase(); + // longer then 63 + public static final String VERY_LONG_CLASS_NAME = + VeryVeryVeryVeryVeryVeryLongClassNameForSakeOfThisTestIfItWorks.class + .getSimpleName() + .toLowerCase(); + + public static ExtensionContext mockExtensionContext(String className, String methodName) { + ExtensionContext extensionContext = mock(ExtensionContext.class); + Method method = mock(Method.class); + + Class clazz; + if (className.equals(SHORT_CLASS_NAME)) { + clazz = Method.class; + } else if (className.equals(LONG_CLASS_NAME)) { + clazz = VeryLongClassNameForSakeOfThisTestIfItWorks.class; + } else if (className.equals(VERY_LONG_CLASS_NAME)) { + clazz = VeryVeryVeryVeryVeryVeryLongClassNameForSakeOfThisTestIfItWorks.class; + } else { + throw new IllegalArgumentException(); + } + + when(method.getName()).thenReturn(methodName); + when(extensionContext.getRequiredTestMethod()).thenReturn(method); + when(extensionContext.getRequiredTestClass()).thenReturn(clazz); + + return extensionContext; + } + + public static class VeryVeryVeryVeryVeryVeryLongClassNameForSakeOfThisTestIfItWorks {} + + public static class VeryLongClassNameForSakeOfThisTestIfItWorks {} +} diff --git a/operator-framework-junit5/src/test/resources/crd/test.crd b/operator-framework-junit5/src/test/resources/crd/test.crd new file mode 100644 index 0000000000..f0891454fe --- /dev/null +++ b/operator-framework-junit5/src/test/resources/crd/test.crd @@ -0,0 +1,19 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: tests.crd.example +spec: + group: crd.example + names: + kind: Test + singular: test + plural: tests + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + type: "object" + served: true + storage: true \ No newline at end of file diff --git a/operator-framework-junit5/src/test/resources/log4j2.xml b/operator-framework-junit5/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..82d8fa2cb1 --- /dev/null +++ b/operator-framework-junit5/src/test/resources/log4j2.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/operator-framework/pom.xml b/operator-framework/pom.xml index 96f21fee60..f1a100eb75 100644 --- a/operator-framework/pom.xml +++ b/operator-framework/pom.xml @@ -1,77 +1,143 @@ - - 4.0.0 + + 4.0.0 + + io.javaoperatorsdk + java-operator-sdk + 5.1.5-SNAPSHOT + - - com.github.containersolutions - java-operator-sdk - 0.3.10-SNAPSHOT - + operator-framework + Operator SDK - Framework - Plain Java - operator-framework - Operator SDK - Framework - Framework for implementing Kubernetes operators - jar + + + io.javaoperatorsdk + operator-framework-core + + + io.fabric8 + kubernetes-httpclient-${fabric8-httpclient-impl.name} + + + org.apache.commons + commons-lang3 + + + com.squareup + javapoet + compile + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + test + + + org.awaitility + awaitility + test + + + com.google.testing.compile + compile-testing + test + + + + io.fabric8 + openshift-client-api + test + + + org.apache.logging.log4j + log4j-slf4j2-impl + test + + + org.apache.logging.log4j + log4j-core + test + + + io.javaoperatorsdk + operator-framework-junit-5 + ${project.version} + test + + + io.fabric8 + kube-api-test-client-inject + test + + - - 8 - 1.8 - 1.8 - - - - - - org.apache.maven.plugins - maven-surefire-plugin - 2.22.2 - - - - - - - com.google.guava - guava - - - io.fabric8 - openshift-client - - - org.apache.commons - commons-lang3 - - - org.slf4j - slf4j-api - - - org.junit.jupiter - junit-jupiter-engine - - - org.mockito - mockito-core - - - org.apache.logging.log4j - log4j-slf4j-impl - 2.11.2 - test - - - org.assertj - assertj-core - 3.4.1 - test - - - org.awaitility - awaitility - 4.0.1 - test - - + + + + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + + default-compile + + compile + + compile + + + -proc:none + + + + + + + io.fabric8 + crd-generator-maven-plugin + ${fabric8-client.version} + + + + generate + + process-test-classes + + ${project.build.testOutputDirectory} + WITH_ALL_DEPENDENCIES_AND_TESTS + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + META-INF/fabric8/*.yml + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/ControllerUtils.java b/operator-framework/src/main/java/com/github/containersolutions/operator/ControllerUtils.java deleted file mode 100644 index bc86520843..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/ControllerUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.containersolutions.operator; - -import com.github.containersolutions.operator.api.Controller; -import com.github.containersolutions.operator.api.ResourceController; -import io.fabric8.kubernetes.client.CustomResource; -import io.fabric8.kubernetes.client.CustomResourceDoneable; -import io.fabric8.kubernetes.client.CustomResourceList; - -class ControllerUtils { - - static String getDefaultFinalizer(ResourceController controller) { - return getAnnotation(controller).finalizerName(); - } - - static Class getCustomResourceClass(ResourceController controller) { - return (Class) getAnnotation(controller).customResourceClass(); - } - - static String getCrdName(ResourceController controller) { - return getAnnotation(controller).crdName(); - } - - static Class> getCustomResourceListClass(ResourceController controller) { - return (Class>) getAnnotation(controller).customResourceListClass(); - } - - static Class> getCustomResourceDonebaleClass(ResourceController controller) { - return (Class>) getAnnotation(controller).customResourceDoneableClass(); - } - - private static Controller getAnnotation(ResourceController controller) { - return controller.getClass().getAnnotation(Controller.class); - } - -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/Operator.java b/operator-framework/src/main/java/com/github/containersolutions/operator/Operator.java deleted file mode 100644 index c5343dfe4b..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/Operator.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.github.containersolutions.operator; - -import com.github.containersolutions.operator.api.ResourceController; -import com.github.containersolutions.operator.processing.EventDispatcher; -import com.github.containersolutions.operator.processing.EventScheduler; -import com.github.containersolutions.operator.processing.retry.GenericRetry; -import com.github.containersolutions.operator.processing.retry.Retry; -import io.fabric8.kubernetes.api.model.apiextensions.CustomResourceDefinition; -import io.fabric8.kubernetes.client.CustomResource; -import io.fabric8.kubernetes.client.CustomResourceDoneable; -import io.fabric8.kubernetes.client.CustomResourceList; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.dsl.MixedOperation; -import io.fabric8.kubernetes.client.dsl.internal.CustomResourceOperationsImpl; -import io.fabric8.kubernetes.internal.KubernetesDeserializer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; - -import static com.github.containersolutions.operator.ControllerUtils.*; - -@SuppressWarnings("rawtypes") -public class Operator { - - private final static Logger log = LoggerFactory.getLogger(Operator.class); - - private final Retry defaultRetry = GenericRetry.defaultLimitedExponentialRetry(); - private final KubernetesClient k8sClient; - private Map, CustomResourceOperationsImpl> customResourceClients = new HashMap<>(); - - public Operator(KubernetesClient k8sClient) { - this.k8sClient = k8sClient; - } - - - public void registerControllerForAllNamespaces(ResourceController controller) throws OperatorException { - registerController(controller, true, defaultRetry); - } - - public void registerControllerForAllNamespaces(ResourceController controller, Retry retry) throws OperatorException { - registerController(controller, true, retry); - } - - public void registerController(ResourceController controller, String... targetNamespaces) throws OperatorException { - registerController(controller, false, defaultRetry, targetNamespaces); - } - - public void registerController(ResourceController controller, Retry retry, String... targetNamespaces) throws OperatorException { - registerController(controller, false, retry, targetNamespaces); - } - - @SuppressWarnings("rawtypes") - private void registerController(ResourceController controller, - boolean watchAllNamespaces, Retry retry, String... targetNamespaces) throws OperatorException { - Class resClass = getCustomResourceClass(controller); - CustomResourceDefinition crd = getCustomResourceDefinitionForController(controller); - KubernetesDeserializer.registerCustomKind(getApiVersion(crd), getKind(crd), resClass); - - Class> list = getCustomResourceListClass(controller); - Class> doneable = getCustomResourceDonebaleClass(controller); - MixedOperation client = k8sClient.customResources(crd, resClass, list, doneable); - EventDispatcher eventDispatcher = new EventDispatcher(controller, - getDefaultFinalizer(controller), new EventDispatcher.CustomResourceReplaceFacade(client)); - EventScheduler eventScheduler = new EventScheduler(eventDispatcher, retry); - registerWatches(controller, client, resClass, watchAllNamespaces, targetNamespaces, eventScheduler); - } - - private void registerWatches(ResourceController controller, MixedOperation client, - Class resClass, - boolean watchAllNamespaces, String[] targetNamespaces, EventScheduler eventScheduler) { - - CustomResourceOperationsImpl crClient = (CustomResourceOperationsImpl) client; - if (watchAllNamespaces) { - // todo test this - crClient.inAnyNamespace().watch(eventScheduler); - } else if (targetNamespaces.length == 0) { - client.watch(eventScheduler); - } else { - for (String targetNamespace : targetNamespaces) { - crClient.inNamespace(targetNamespace).watch(eventScheduler); - log.debug("Registered controller for namespace: {}", targetNamespace); - } - } - customResourceClients.put(resClass, (CustomResourceOperationsImpl) client); - log.info("Registered Controller: '{}' for CRD: '{}' for namespaces: {}", controller.getClass().getSimpleName(), - resClass, targetNamespaces.length == 0 ? "[all/client namespace]" : Arrays.toString(targetNamespaces)); - } - - private CustomResourceDefinition getCustomResourceDefinitionForController(ResourceController controller) { - String crdName = getCrdName(controller); - CustomResourceDefinition customResourceDefinition = k8sClient.customResourceDefinitions().withName(crdName).get(); - if (customResourceDefinition == null) { - throw new OperatorException("Cannot find Custom Resource Definition with name: " + crdName); - } - return customResourceDefinition; - } - - public Map, CustomResourceOperationsImpl> getCustomResourceClients() { - return customResourceClients; - } - - public void stop() { - k8sClient.close(); - } - - public CustomResourceOperationsImpl getCustomResourceClients(Class customResourceClass) { - return customResourceClients.get(customResourceClass); - } - - private String getKind(CustomResourceDefinition crd) { - return crd.getSpec().getNames().getKind(); - } - - private String getApiVersion(CustomResourceDefinition crd) { - return crd.getSpec().getGroup() + "/" + crd.getSpec().getVersion(); - } -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/OperatorException.java b/operator-framework/src/main/java/com/github/containersolutions/operator/OperatorException.java deleted file mode 100644 index 232c794ac5..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/OperatorException.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.github.containersolutions.operator; - -public class OperatorException extends RuntimeException { - - public OperatorException() { - } - - public OperatorException(String message) { - super(message); - } - - public OperatorException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/api/Controller.java b/operator-framework/src/main/java/com/github/containersolutions/operator/api/Controller.java deleted file mode 100644 index c526339ca5..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/api/Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.github.containersolutions.operator.api; - -import io.fabric8.kubernetes.client.CustomResource; -import io.fabric8.kubernetes.client.CustomResourceDoneable; -import io.fabric8.kubernetes.client.CustomResourceList; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Retention(RetentionPolicy.RUNTIME) -@Target({ElementType.TYPE}) -public @interface Controller { - - String DEFAULT_FINALIZER = "operator.default.finalizer"; - - String crdName(); - - Class customResourceClass(); - - Class> customResourceListClass(); - - Class> customResourceDoneableClass(); - - String finalizerName() default DEFAULT_FINALIZER; - -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/api/ResourceController.java b/operator-framework/src/main/java/com/github/containersolutions/operator/api/ResourceController.java deleted file mode 100644 index a4d6c760cb..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/api/ResourceController.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.containersolutions.operator.api; - -import io.fabric8.kubernetes.client.CustomResource; - -import java.util.Optional; - -public interface ResourceController { - - /** - * The implementation should delete the associated component(s). Note that this is method is called when an object - * is marked for deletion. After its executed the default finalizer is automatically removed by the framework; - * unless the return value is false - note that this is almost never the case. - * Its important to have the implementation also idempotent, in the current implementation to cover all edge cases - * actually will be executed mostly twice. - * - * @param resource - * @return true - so the finalizer is automatically removed after the call. - * false if you don't want to remove the finalizer. Note that this is ALMOST NEVER the case. - */ - boolean deleteResource(R resource); - - /** - * The implementation of this operation is required to be idempotent. - * - * @return The resource is updated in api server if the return value is present - * within Optional. This the common use cases. However in cases, for example the operator is restarted, - * and we don't want to have an update call to k8s api to be made unnecessarily, by returning an empty Optional - * this update can be skipped. - * However we will always call an update if there is no finalizer on object and its not marked for deletion. - */ - Optional createOrUpdateResource(R resource); - -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/CustomResourceEvent.java b/operator-framework/src/main/java/com/github/containersolutions/operator/processing/CustomResourceEvent.java deleted file mode 100644 index 4cc78f09bc..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/CustomResourceEvent.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.github.containersolutions.operator.processing; - -import com.github.containersolutions.operator.processing.retry.Retry; -import com.github.containersolutions.operator.processing.retry.RetryExecution; -import io.fabric8.kubernetes.client.CustomResource; -import io.fabric8.kubernetes.client.Watcher; - -import java.util.Optional; - -public class CustomResourceEvent { - - private final RetryExecution retryExecution; - private final Watcher.Action action; - private final CustomResource resource; - private int retryCount = -1; - - CustomResourceEvent(Watcher.Action action, CustomResource resource, Retry retry) { - this.action = action; - this.resource = resource; - this.retryExecution = retry.initExecution(); - } - - Watcher.Action getAction() { - return action; - } - - public CustomResource getResource() { - return resource; - } - - public String resourceUid() { - return resource.getMetadata().getUid(); - } - - public Boolean sameResourceAs(CustomResourceEvent otherEvent) { - return getResource().getMetadata().getUid().equals(otherEvent.getResource().getMetadata().getUid()); - } - - public Boolean isSameResourceAndNewerVersion(CustomResourceEvent otherEvent) { - return sameResourceAs(otherEvent) && - Long.parseLong(getResource().getMetadata().getResourceVersion()) > - Long.parseLong(otherEvent.getResource().getMetadata().getResourceVersion()); - - } - - public Optional nextBackOff() { - retryCount++; - return retryExecution.nextDelay(); - } - - @Override - public String toString() { - return "CustomResourceEvent{" + - "action=" + action + - ", resource=[ name=" + resource.getMetadata().getName() + ", kind=" + resource.getKind() + - ", apiVersion=" + resource.getApiVersion() + " ,resourceVersion=" + resource.getMetadata().getResourceVersion() + - ", markedForDeletion: " + (resource.getMetadata().getDeletionTimestamp() != null - && !resource.getMetadata().getDeletionTimestamp().isEmpty()) + - " ], retriesIndex=" + retryCount + - '}'; - } -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventConsumer.java b/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventConsumer.java deleted file mode 100644 index 6db8e219cb..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventConsumer.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.github.containersolutions.operator.processing; - -import io.fabric8.kubernetes.client.CustomResource; -import io.fabric8.kubernetes.client.Watcher; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@SuppressWarnings("rawtypes") -class EventConsumer implements Runnable { - - private final static Logger log = LoggerFactory.getLogger(EventConsumer.class); - - private final CustomResourceEvent event; - private final EventDispatcher eventDispatcher; - private final EventScheduler eventScheduler; - - EventConsumer(CustomResourceEvent event, EventDispatcher eventDispatcher, EventScheduler eventScheduler) { - this.event = event; - this.eventDispatcher = eventDispatcher; - this.eventScheduler = eventScheduler; - } - - @Override - public void run() { - log.debug("Processing event started: {}", event); - if (processEvent()) { - eventScheduler.eventProcessingFinishedSuccessfully(event); - log.debug("Event processed successfully: {}", event); - } else { - this.eventScheduler.eventProcessingFailed(event); - log.debug("Event processed failed: {}", event); - } - } - - @SuppressWarnings("unchecked") - private boolean processEvent() { - Watcher.Action action = event.getAction(); - CustomResource resource = event.getResource(); - try { - eventDispatcher.handleEvent(action, resource); - } catch (RuntimeException e) { - log.error("Processing event {} failed.", event, e); - return false; - } - return true; - } -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventDispatcher.java b/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventDispatcher.java deleted file mode 100644 index f916f93ede..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventDispatcher.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.github.containersolutions.operator.processing; - -import com.github.containersolutions.operator.api.ResourceController; -import io.fabric8.kubernetes.client.CustomResource; -import io.fabric8.kubernetes.client.Watcher; -import io.fabric8.kubernetes.client.dsl.MixedOperation; -import io.fabric8.kubernetes.client.dsl.Resource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.Optional; - -/** - * Dispatches events to the Controller and handles Finalizers for a single type of Custom Resource. - */ -public class EventDispatcher { - - private final static Logger log = LoggerFactory.getLogger(EventDispatcher.class); - - private final ResourceController controller; - private final String resourceDefaultFinalizer; - private final CustomResourceReplaceFacade customResourceReplaceFacade; - - public EventDispatcher(ResourceController controller, - String defaultFinalizer, - CustomResourceReplaceFacade customResourceReplaceFacade) { - this.controller = controller; - this.customResourceReplaceFacade = customResourceReplaceFacade; - this.resourceDefaultFinalizer = defaultFinalizer; - } - - public void handleEvent(Watcher.Action action, CustomResource resource) { - log.info("Handling event {} for resource {}", action, resource.getMetadata()); - if (Watcher.Action.ERROR == action) { - log.error("Received error for resource: {}", resource.getMetadata().getName()); - return; - } - // Its interesting problem if we should call delete if received event after object is marked for deletion - // but there is not our finalizer. Since it can happen that there are multiple finalizers, also other events after - // we called delete and remove finalizers already. But also it can happen that we did not manage to put - // finalizer into the resource before marked for delete. So for now we will call delete every time, since delete - // operation should be idempotent too, and this way we cover the corner case. - if (markedForDeletion(resource) || action == Watcher.Action.DELETED) { - boolean removeFinalizer = controller.deleteResource(resource); - if (removeFinalizer && hasDefaultFinalizer(resource)) { - log.debug("Removing finalizer on {}: {}", resource.getMetadata().getName(), resource.getMetadata()); - removeDefaultFinalizer(resource); - } - } else { - Optional updateResult = controller.createOrUpdateResource(resource); - if (updateResult.isPresent()) { - log.debug("Updating resource: {} with version: {}", resource.getMetadata().getName(), - resource.getMetadata().getResourceVersion()); - log.trace("Resource before update: {}", resource); - CustomResource updatedResource = updateResult.get(); - addFinalizerIfNotPresent(updatedResource); - replace(updatedResource); - log.trace("Resource after update: {}", resource); - // We always add the default finalizer if missing and not marked for deletion. - } else if (!hasDefaultFinalizer(resource) && !markedForDeletion(resource)) { - log.debug("Adding finalizer for resource: {} version: {}", resource.getMetadata().getName(), - resource.getMetadata().getResourceVersion()); - addFinalizerIfNotPresent(resource); - replace(resource); - } - } - } - - private boolean hasDefaultFinalizer(CustomResource resource) { - if (resource.getMetadata().getFinalizers() != null) { - return resource.getMetadata().getFinalizers().contains(resourceDefaultFinalizer); - } - return false; - } - - private void removeDefaultFinalizer(CustomResource resource) { - resource.getMetadata().getFinalizers().remove(resourceDefaultFinalizer); - log.debug("Removed finalizer. Trying to replace resource {}, version: {}", resource.getMetadata().getName(), resource.getMetadata().getResourceVersion()); - customResourceReplaceFacade.replaceWithLock(resource); - } - - private void replace(CustomResource resource) { - log.debug("Trying to replace resource {}, version: {}", resource.getMetadata().getName(), resource.getMetadata().getResourceVersion()); - customResourceReplaceFacade.replaceWithLock(resource); - } - - private void addFinalizerIfNotPresent(CustomResource resource) { - if (!hasDefaultFinalizer(resource) && !markedForDeletion(resource)) { - log.info("Adding default finalizer to {}", resource.getMetadata()); - if (resource.getMetadata().getFinalizers() == null) { - resource.getMetadata().setFinalizers(new ArrayList<>(1)); - } - resource.getMetadata().getFinalizers().add(resourceDefaultFinalizer); - } - } - - private boolean markedForDeletion(CustomResource resource) { - return resource.getMetadata().getDeletionTimestamp() != null && !resource.getMetadata().getDeletionTimestamp().isEmpty(); - } - - // created to support unit testing - public static class CustomResourceReplaceFacade { - - private final MixedOperation> resourceOperation; - - public CustomResourceReplaceFacade(MixedOperation> resourceOperation) { - this.resourceOperation = resourceOperation; - } - - public CustomResource replaceWithLock(CustomResource resource) { - return resourceOperation.inNamespace(resource.getMetadata().getNamespace()) - .withName(resource.getMetadata().getName()) - .lockResourceVersion(resource.getMetadata().getResourceVersion()) - .replace(resource); - } - } -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventScheduler.java b/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventScheduler.java deleted file mode 100644 index 8030f1d199..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventScheduler.java +++ /dev/null @@ -1,163 +0,0 @@ -package com.github.containersolutions.operator.processing; - - -import com.github.containersolutions.operator.processing.retry.Retry; -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import io.fabric8.kubernetes.client.CustomResource; -import io.fabric8.kubernetes.client.KubernetesClientException; -import io.fabric8.kubernetes.client.Watcher; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Optional; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; - -/** - * Requirements: - *
    - *
  • Only 1 event should be processed at a time for same custom resource - * (metadata.name is the id, but kind and api should be taken into account)
  • - *
  • If event processing fails it should be rescheduled with retry - with limited number of retried - * and exponential time slacks (pluggable reschedule strategy in future?)
  • - *
  • if there are multiple events received for the same resource process only the last one. (Others can be discarded) - * User resourceVersion to check which is the latest. Put the new one at the and of the queue? - *
  • - *
  • Done - Avoid starvation, so on retry put back resource at the end of the queue.
  • - *
  • The selecting event from a queue should not be naive. So for example: - * If we cannot pick the last event because an event for that resource is currently processing just gor for the next one. - *
  • - *
  • Threading approach thus thread pool size and/or implementation should be configurable
  • - *
- *

- * Notes: - *

    - *
  • In implementation we have to lock since the fabric8 client event handling is multi-threaded, we can receive multiple events - * for same resource. Also we do callback from other threads. - *
  • - *
- */ - -public class EventScheduler implements Watcher { - - private final static Logger log = LoggerFactory.getLogger(EventScheduler.class); - - private final EventDispatcher eventDispatcher; - private final ScheduledThreadPoolExecutor executor; - private final EventStore eventStore = new EventStore(); - private final Retry retry; - - private ReentrantLock lock = new ReentrantLock(); - - public EventScheduler(EventDispatcher eventDispatcher, Retry retry) { - this.eventDispatcher = eventDispatcher; - this.retry = retry; - ThreadFactory threadFactory = new ThreadFactoryBuilder() - .setNameFormat("event-consumer-%d") - .setDaemon(false) - .build(); - executor = new ScheduledThreadPoolExecutor(1, threadFactory); - executor.setRemoveOnCancelPolicy(true); - } - - @Override - public void eventReceived(Watcher.Action action, CustomResource resource) { - log.debug("Event received for action: {}, {}: {}", action.toString().toLowerCase(), resource.getClass().getSimpleName(), - resource.getMetadata().getName()); - CustomResourceEvent event = new CustomResourceEvent(action, resource, retry); - scheduleEvent(event); - } - - void scheduleEvent(CustomResourceEvent event) { - log.trace("Current queue size {}", executor.getQueue().size()); - log.debug("Scheduling event: {}", event); - try { - lock.lock(); - if (event.getResource().getMetadata().getDeletionTimestamp() != null && event.getAction() == Action.DELETED) { - // Note that we always use finalizers, we want to process delete event just in corner case, - // when we are not able to add finalizer (lets say because of optimistic locking error, and the resource was deleted instantly). - // We want to skip in case of finalizer was there since we don't want to execute delete method always at least 2x, - // which would be the result if we don't skip here. (If there is no deletion timestamp if resource deleted without finalizer. - log.debug("Skipping delete event since deletion timestamp is present on resource, so finalizer was in place."); - return; - } - if (eventStore.receivedMoreRecentEventBefore(event)) { - log.debug("Skipping event processing since was processed event with newer version before. {}", event); - return; - } - eventStore.updateLatestResourceVersionReceived(event); - - if (eventStore.containsOlderVersionOfNotScheduledEvent(event)) { - log.debug("Replacing event which is not scheduled yet, since incoming event is more recent. new Event:{}", event); - eventStore.addOrReplaceEventAsNotScheduledYet(event); - return; - } - if (eventStore.containsOlderVersionOfEventUnderProcessing(event)) { - log.debug("Scheduling event for later processing since there is an event under processing for same kind." + - " New event: {}", event); - eventStore.addOrReplaceEventAsNotScheduledYet(event); - return; - } - - Optional nextBackOff = event.nextBackOff(); - if (!nextBackOff.isPresent()) { - log.warn("Event max retry limit reached. Will be discarded. {}", event); - return; - } - log.debug("Creating scheduled task for event: {}", event); - executor.schedule(new EventConsumer(event, eventDispatcher, this), - nextBackOff.get(), TimeUnit.MILLISECONDS); - eventStore.addEventUnderProcessing(event); - } finally { - log.debug("Scheduling event finished: {}", event); - lock.unlock(); - } - } - - void eventProcessingFinishedSuccessfully(CustomResourceEvent event) { - try { - lock.lock(); - eventStore.removeEventUnderProcessing(event.resourceUid()); - CustomResourceEvent notScheduledYetEvent = eventStore.removeEventNotScheduledYet(event.resourceUid()); - if (notScheduledYetEvent != null) { - scheduleEvent(notScheduledYetEvent); - } - } finally { - lock.unlock(); - } - } - - void eventProcessingFailed(CustomResourceEvent event) { - try { - lock.lock(); - eventStore.removeEventUnderProcessing(event.resourceUid()); - CustomResourceEvent notScheduledYetEvent = eventStore.removeEventNotScheduledYet(event.resourceUid()); - if (notScheduledYetEvent != null) { - if (!notScheduledYetEvent.isSameResourceAndNewerVersion(event)) { - log.warn("The not yet scheduled event has older version then actual event. This is probably a bug."); - } - // this is the case when we failed processing an event but we already received a new one. - // Since since we process declarative resources it correct to schedule the new event. - scheduleEvent(notScheduledYetEvent); - } else { - scheduleEvent(event); - } - } finally { - lock.unlock(); - } - } - - @Override - public void onClose(KubernetesClientException e) { - if (e != null) { - log.error("Error: ", e); - // we will exit the application if there was a watching exception, because of the bug in fabric8 client - // see https://github.com/fabric8io/kubernetes-client/issues/1318 - System.exit(1); - } - } -} - - diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventStore.java b/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventStore.java deleted file mode 100644 index 3f53ea745d..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/EventStore.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.github.containersolutions.operator.processing; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.Map; - -public class EventStore { - - private final static Logger log = LoggerFactory.getLogger(EventStore.class); - - private final Map lastResourceVersion = new HashMap<>(); - private final Map eventsNotScheduledYet = new HashMap<>(); - private final Map eventsUnderProcessing = new HashMap<>(); - - public boolean containsOlderVersionOfNotScheduledEvent(CustomResourceEvent newEvent) { - return eventsNotScheduledYet.containsKey(newEvent.resourceUid()) && - newEvent.isSameResourceAndNewerVersion(eventsNotScheduledYet.get(newEvent.resourceUid())); - } - - public CustomResourceEvent removeEventNotScheduledYet(String uid) { - return eventsNotScheduledYet.remove(uid); - } - - public void addOrReplaceEventAsNotScheduledYet(CustomResourceEvent event) { - eventsNotScheduledYet.put(event.resourceUid(), event); - } - - public boolean containsOlderVersionOfEventUnderProcessing(CustomResourceEvent newEvent) { - return eventsUnderProcessing.containsKey(newEvent.resourceUid()) && - newEvent.isSameResourceAndNewerVersion(eventsUnderProcessing.get(newEvent.resourceUid())); - } - - - public void addEventUnderProcessing(CustomResourceEvent event) { - eventsUnderProcessing.put(event.resourceUid(), event); - } - - public CustomResourceEvent removeEventUnderProcessing(String uid) { - return eventsUnderProcessing.remove(uid); - } - - public void updateLatestResourceVersionReceived(CustomResourceEvent event) { - Long current = lastResourceVersion.get(event.resourceUid()); - long received = Long.parseLong(event.getResource().getMetadata().getResourceVersion()); - if (current == null || received > current) { - lastResourceVersion.put(event.resourceUid(), received); - log.debug("Resource version for {} updated from {} to {}", event.getResource().getMetadata().getName(), current, received); - } else { - log.debug("Resource version for {} not updated from {}", event.getResource().getMetadata().getName(), current); - } - } - - public boolean receivedMoreRecentEventBefore(CustomResourceEvent customResourceEvent) { - Long lastVersionProcessed = lastResourceVersion.get(customResourceEvent.resourceUid()); - if (lastVersionProcessed == null) { - return false; - } else { - return lastVersionProcessed > Long.parseLong(customResourceEvent.getResource() - .getMetadata().getResourceVersion()); - } - } -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/GenericRetry.java b/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/GenericRetry.java deleted file mode 100644 index 6ef7bb94a5..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/GenericRetry.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.github.containersolutions.operator.processing.retry; - -public class GenericRetry implements Retry { - - public static final int DEFAULT_MAX_ATTEMPTS = 5; - public static final long DEFAULT_INITIAL_INTERVAL = 2000L; - public static final double DEFAULT_MULTIPLIER = 1.5D; - - private int maxAttempts = DEFAULT_MAX_ATTEMPTS; - private long initialInterval = DEFAULT_INITIAL_INTERVAL; - private double intervalMultiplier = DEFAULT_MULTIPLIER; - private long maxInterval = -1; - private long maxElapsedTime = -1; - - public static GenericRetry defaultLimitedExponentialRetry() { - return new GenericRetry(); - } - - public static GenericRetry noRetry() { - return new GenericRetry().setMaxAttempts(1); - } - - public static GenericRetry every10second10TimesRetry() { - return new GenericRetry() - .withLinearRetry() - .setMaxAttempts(10) - .setInitialInterval(10000); - } - - @Override - public GenericRetryExecution initExecution() { - return new GenericRetryExecution(this); - } - - public int getMaxAttempts() { - return maxAttempts; - } - - public GenericRetry setMaxAttempts(int maxRetryAttempts) { - this.maxAttempts = maxRetryAttempts; - return this; - } - - public long getInitialInterval() { - return initialInterval; - } - - public GenericRetry setInitialInterval(long initialInterval) { - this.initialInterval = initialInterval; - return this; - } - - public double getIntervalMultiplier() { - return intervalMultiplier; - } - - public GenericRetry setIntervalMultiplier(double intervalMultiplier) { - this.intervalMultiplier = intervalMultiplier; - return this; - } - - public long getMaxInterval() { - return maxInterval; - } - - public GenericRetry setMaxInterval(long maxInterval) { - this.maxInterval = maxInterval; - return this; - } - - public long getMaxElapsedTime() { - return maxElapsedTime; - } - - public GenericRetry setMaxElapsedTime(long maxElapsedTime) { - this.maxElapsedTime = maxElapsedTime; - return this; - } - - public GenericRetry withoutMaxInterval() { - this.maxInterval = -1; - return this; - } - - public GenericRetry withoutMaxElapsedTime() { - this.maxElapsedTime = -1; - return this; - } - - public GenericRetry withoutMaxAttempts() { - return this.setMaxAttempts(-1); - } - - public GenericRetry withLinearRetry() { - this.intervalMultiplier = 1; - return this; - } -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/GenericRetryExecution.java b/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/GenericRetryExecution.java deleted file mode 100644 index 684ae6d060..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/GenericRetryExecution.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.github.containersolutions.operator.processing.retry; - -import java.util.Optional; - - -public class GenericRetryExecution implements RetryExecution { - - private final GenericRetry genericRetry; - - private int lastAttemptIndex = 0; - private long currentInterval; - private long elapsedTime = 0; - - public GenericRetryExecution(GenericRetry genericRetry) { - this.genericRetry = genericRetry; - this.currentInterval = genericRetry.getInitialInterval(); - } - - /** - * Note that first attempt is always 0. Since this implementation is tailored for event scheduling. - */ - public Optional nextDelay() { - if (lastAttemptIndex == 0) { - lastAttemptIndex++; - return Optional.of(0l); - } - if (genericRetry.getMaxElapsedTime() > 0 && lastAttemptIndex > 0) { - elapsedTime += currentInterval; - if (elapsedTime > genericRetry.getMaxElapsedTime()) { - return Optional.empty(); - } - } - if (genericRetry.getMaxAttempts() > -1 && lastAttemptIndex >= genericRetry.getMaxAttempts()) { - return Optional.empty(); - } - - if (lastAttemptIndex > 1) { - currentInterval = (long) (currentInterval * genericRetry.getIntervalMultiplier()); - if (genericRetry.getMaxInterval() > -1 && currentInterval > genericRetry.getMaxInterval()) { - currentInterval = genericRetry.getMaxInterval(); - } - } - lastAttemptIndex++; - return Optional.of(currentInterval); - } -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/Retry.java b/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/Retry.java deleted file mode 100644 index 99546a4fbb..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/Retry.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.containersolutions.operator.processing.retry; - -public interface Retry { - - RetryExecution initExecution(); - -} diff --git a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/RetryExecution.java b/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/RetryExecution.java deleted file mode 100644 index 97baa77a8d..0000000000 --- a/operator-framework/src/main/java/com/github/containersolutions/operator/processing/retry/RetryExecution.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.containersolutions.operator.processing.retry; - -import java.util.Optional; - -public interface RetryExecution { - - /** - * Calculates the delay for the next execution. This method should return 0, when called first time; - * - * @return - */ - Optional nextDelay(); - -} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/AccumulativeMappingWriter.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/AccumulativeMappingWriter.java new file mode 100644 index 0000000000..6e70a60f21 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/AccumulativeMappingWriter.java @@ -0,0 +1,82 @@ +package io.javaoperatorsdk.operator.config.runtime; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.tools.StandardLocation; + +/** + * The writer is able to load an existing resource file as a Map and override it with the new + * mappings added to the existing mappings. Every entry corresponds to a line in the resource file + * where key and values are separated by comma. + */ +class AccumulativeMappingWriter { + + private final Map mappings = new ConcurrentHashMap<>(); + private final String resourcePath; + private final ProcessingEnvironment processingEnvironment; + + public AccumulativeMappingWriter( + String resourcePath, ProcessingEnvironment processingEnvironment) { + this.resourcePath = resourcePath; + this.processingEnvironment = processingEnvironment; + } + + public AccumulativeMappingWriter loadExistingMappings() { + try { + final var readonlyResource = + processingEnvironment + .getFiler() + .getResource(StandardLocation.CLASS_OUTPUT, "", resourcePath); + + try (BufferedReader bufferedReader = + new BufferedReader(new InputStreamReader(readonlyResource.openInputStream()))) { + final var existingLines = + bufferedReader + .lines() + .map(l -> l.split(",")) + .collect(Collectors.toMap(parts -> parts[0], parts -> parts[1])); + mappings.putAll(existingLines); + } + } catch (IOException e) { + } + return this; + } + + /** Add a new mapping */ + public AccumulativeMappingWriter add(String key, String value) { + this.mappings.put(key, value); + return this; + } + + /** + * Generates or override the resource file with the given path ({@link + * AccumulativeMappingWriter#resourcePath}) + */ + public void flush() { + PrintWriter printWriter = null; + try { + final var resource = + processingEnvironment + .getFiler() + .createResource(StandardLocation.CLASS_OUTPUT, "", resourcePath); + printWriter = new PrintWriter(resource.openOutputStream()); + + for (Map.Entry entry : mappings.entrySet()) { + printWriter.println(entry.getKey() + "," + entry.getValue()); + } + } catch (IOException e) { + throw new IllegalStateException(e); + } finally { + if (printWriter != null) { + printWriter.close(); + } + } + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/ClassMappingProvider.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/ClassMappingProvider.java new file mode 100644 index 0000000000..0bc2525a86 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/ClassMappingProvider.java @@ -0,0 +1,61 @@ +package io.javaoperatorsdk.operator.config.runtime; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URL; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.ClassUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class ClassMappingProvider { + + private static final Logger log = LoggerFactory.getLogger(ClassMappingProvider.class); + + @SuppressWarnings("unchecked") + static Map provide(final String resourcePath, T key, V value) { + Map result = new HashMap<>(); + try { + final var classLoader = Thread.currentThread().getContextClassLoader(); + final Enumeration resourcesMetadataList = classLoader.getResources(resourcePath); + for (Iterator it = resourcesMetadataList.asIterator(); it.hasNext(); ) { + URL url = it.next(); + + List classNamePairs = retrieveClassNamePairs(url); + classNamePairs.forEach( + clazzPair -> { + try { + final String[] classNames = clazzPair.split(","); + if (classNames.length != 2) { + throw new IllegalStateException( + String.format( + "%s is not valid Mapping metadata, defined in %s", clazzPair, url)); + } + + result.put( + (T) ClassUtils.getClass(classNames[0]), (V) ClassUtils.getClass(classNames[1])); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + }); + } + log.debug("Loaded Controller to resource mappings {}", result); + return result; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static List retrieveClassNamePairs(URL url) throws IOException { + try (BufferedReader br = new BufferedReader(new InputStreamReader(url.openStream()))) { + return br.lines().collect(Collectors.toList()); + } + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/ControllerConfigurationAnnotationProcessor.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/ControllerConfigurationAnnotationProcessor.java new file mode 100644 index 0000000000..a2df87fefb --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/ControllerConfigurationAnnotationProcessor.java @@ -0,0 +1,110 @@ +package io.javaoperatorsdk.operator.config.runtime; + +import java.util.Set; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeMirror; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.client.CustomResource; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; + +import com.squareup.javapoet.TypeName; + +import static io.javaoperatorsdk.operator.config.runtime.RuntimeControllerMetadata.RECONCILERS_RESOURCE_PATH; + +@SupportedAnnotationTypes("io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration") +public class ControllerConfigurationAnnotationProcessor extends AbstractProcessor { + + private static final Logger log = + LoggerFactory.getLogger(ControllerConfigurationAnnotationProcessor.class); + + private AccumulativeMappingWriter controllersResourceWriter; + private TypeParameterResolver typeParameterResolver; + + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latest(); + } + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + controllersResourceWriter = + new AccumulativeMappingWriter(RECONCILERS_RESOURCE_PATH, processingEnv) + .loadExistingMappings(); + + typeParameterResolver = initializeResolver(processingEnv); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + try { + for (TypeElement annotation : annotations) { + Set annotatedElements = roundEnv.getElementsAnnotatedWith(annotation); + annotatedElements.stream() + .filter(element -> element.getKind().equals(ElementKind.CLASS)) + .map(e -> (TypeElement) e) + .forEach(this::recordCRType); + } + } finally { + if (roundEnv.processingOver()) { + controllersResourceWriter.flush(); + } + } + return true; + } + + private TypeParameterResolver initializeResolver(ProcessingEnvironment processingEnv) { + final DeclaredType resourceControllerType = + processingEnv + .getTypeUtils() + .getDeclaredType( + processingEnv.getElementUtils().getTypeElement(Reconciler.class.getCanonicalName()), + processingEnv.getTypeUtils().getWildcardType(null, null)); + return new TypeParameterResolver(resourceControllerType, 0); + } + + private void recordCRType(TypeElement controllerClassSymbol) { + try { + final TypeMirror resourceType = findResourceType(controllerClassSymbol); + if (resourceType == null) { + controllersResourceWriter.add( + controllerClassSymbol.getQualifiedName().toString(), + CustomResource.class.getCanonicalName()); + System.out.println( + "No defined resource type for '" + + controllerClassSymbol.getQualifiedName() + + "': ignoring!"); + return; + } + final TypeName customResourceType = TypeName.get(resourceType); + controllersResourceWriter.add( + controllerClassSymbol.getQualifiedName().toString(), customResourceType.toString()); + + } catch (Exception ioException) { + log.error("Error", ioException); + } + } + + private TypeMirror findResourceType(TypeElement controllerClassSymbol) { + try { + + return typeParameterResolver.resolve( + processingEnv.getTypeUtils(), (DeclaredType) controllerClassSymbol.asType()); + } catch (Exception e) { + log.error("Error", e); + return null; + } + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationService.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationService.java new file mode 100644 index 0000000000..1a1214aa78 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationService.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.config.runtime; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.ResolvedControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; + +public class DefaultConfigurationService extends BaseConfigurationService { + + @Override + protected ControllerConfiguration configFor(Reconciler reconciler) { + final var other = super.configFor(reconciler); + return new ResolvedControllerConfiguration<>(other); + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/RuntimeControllerMetadata.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/RuntimeControllerMetadata.java new file mode 100644 index 0000000000..0fda410406 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/RuntimeControllerMetadata.java @@ -0,0 +1,33 @@ +package io.javaoperatorsdk.operator.config.runtime; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; + +@SuppressWarnings("rawtypes") +public class RuntimeControllerMetadata { + + public static final String RECONCILERS_RESOURCE_PATH = "javaoperatorsdk/reconcilers"; + private static final Map, Class> + controllerToCustomResourceMappings; + + static { + controllerToCustomResourceMappings = + ClassMappingProvider.provide( + RECONCILERS_RESOURCE_PATH, Reconciler.class, HasMetadata.class); + } + + @SuppressWarnings("unchecked") + static Class getResourceClass(Reconciler reconciler) { + final Class resourceClass = + controllerToCustomResourceMappings.get(reconciler.getClass()); + if (resourceClass == null) { + throw new IllegalArgumentException( + String.format( + "No custom resource has been found for controller %s", + reconciler.getClass().getCanonicalName())); + } + return (Class) resourceClass; + } +} diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/TypeParameterResolver.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/TypeParameterResolver.java new file mode 100644 index 0000000000..ef4af729f1 --- /dev/null +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/TypeParameterResolver.java @@ -0,0 +1,156 @@ +package io.javaoperatorsdk.operator.config.runtime; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.TypeParameterElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.type.TypeVariable; +import javax.lang.model.util.Types; + +import static javax.lang.model.type.TypeKind.DECLARED; +import static javax.lang.model.type.TypeKind.TYPEVAR; + +/** This class can resolve a type parameter in the given index to the actual type defined. */ +class TypeParameterResolver { + + private final DeclaredType interestedClass; + private final int interestedTypeArgumentIndex; + + public TypeParameterResolver(DeclaredType interestedClass, int interestedTypeArgumentIndex) { + + this.interestedClass = interestedClass; + this.interestedTypeArgumentIndex = interestedTypeArgumentIndex; + } + + /** + * @param typeUtils Type utilities, During the annotation processing processingEnv.getTypeUtils() + * can be passed. + * @param declaredType Class or Interface which extends or implements the interestedClass, and the + * interest is getting the actual declared type is used. + * @return the type of the parameter if it can be resolved from the given declareType, otherwise + * it returns null + */ + public TypeMirror resolve(Types typeUtils, DeclaredType declaredType) { + final var chain = findChain(typeUtils, declaredType); + var lastIndex = chain.size() - 1; + String typeName = ""; + final List typeArguments = (chain.get(lastIndex)).getTypeArguments(); + if (typeArguments.isEmpty()) { + return null; + } + if (typeArguments.get(interestedTypeArgumentIndex).getKind() == TYPEVAR) { + typeName = + ((TypeVariable) typeArguments.get(interestedTypeArgumentIndex)) + .asElement() + .getSimpleName() + .toString(); + } else if (typeArguments.get(interestedTypeArgumentIndex).getKind() == DECLARED) { + return typeArguments.get(0); + } + + while (lastIndex > 0) { + lastIndex -= 1; + final List tArguments = (chain.get(lastIndex)).getTypeArguments(); + final List typeParameters = + ((TypeElement) ((chain.get(lastIndex)).asElement())).getTypeParameters(); + + final var typeIndex = getTypeIndexWithName(typeName, typeParameters); + + final TypeMirror matchedType = tArguments.get(typeIndex); + if (matchedType.getKind() == TYPEVAR) { + typeName = ((TypeVariable) matchedType).asElement().getSimpleName().toString(); + } else if (matchedType.getKind() == DECLARED) { + return matchedType; + } + } + return null; + } + + private int getTypeIndexWithName( + String typeName, List typeParameters) { + return IntStream.range(0, typeParameters.size()) + .filter(i -> typeParameters.get(i).getSimpleName().toString().equals(typeName)) + .findFirst() + .orElseThrow(); + } + + private List findChain(Types typeUtils, DeclaredType declaredType) { + + final var result = new ArrayList(); + result.add(declaredType); + var superElement = ((TypeElement) declaredType.asElement()); + var superclass = (DeclaredType) superElement.getSuperclass(); + + final var matchingInterfaces = getMatchingInterfaces(typeUtils, superElement); + // if chain of interfaces is not empty, there is no reason to continue the lookup + // as interfaces do not extend the classes + if (matchingInterfaces.size() > 0) { + result.addAll(matchingInterfaces); + return result; + } + + while (superclass.getKind() != TypeKind.NONE) { + + if (typeUtils.isAssignable(superclass, interestedClass)) { + result.add(superclass); + } + + superElement = (TypeElement) superclass.asElement(); + ArrayList ifs = getMatchingInterfaces(typeUtils, superElement); + if (ifs.size() > 0) { + result.addAll(ifs); + return result; + } + + if (superElement.getSuperclass().getKind() == TypeKind.NONE) { + break; + } + superclass = (DeclaredType) superElement.getSuperclass(); + } + return result; + } + + private ArrayList getMatchingInterfaces(Types typeUtils, TypeElement superElement) { + final var result = new ArrayList(); + + final var matchedInterfaces = + superElement.getInterfaces().stream() + .filter(intface -> typeUtils.isAssignable(intface, interestedClass)) + .map(i -> (DeclaredType) i) + .collect(Collectors.toList()); + if (matchedInterfaces.size() > 0) { + result.addAll(matchedInterfaces); + final var lastFoundInterface = result.get(result.size() - 1); + final var marchingInterfaces = findChainOfInterfaces(typeUtils, lastFoundInterface); + result.addAll(marchingInterfaces); + } + return result; + } + + private List findChainOfInterfaces(Types typeUtils, DeclaredType parentInterface) { + final var result = new ArrayList(); + var matchingInterfaces = + ((TypeElement) parentInterface.asElement()) + .getInterfaces().stream() + .filter(i -> typeUtils.isAssignable(i, interestedClass)) + .map(i -> (DeclaredType) i) + .collect(Collectors.toList()); + while (matchingInterfaces.size() > 0) { + result.addAll(matchingInterfaces); + final var lastFoundInterface = matchingInterfaces.get(matchingInterfaces.size() - 1); + matchingInterfaces = + ((TypeElement) lastFoundInterface.asElement()) + .getInterfaces().stream() + .filter(i -> typeUtils.isAssignable(i, interestedClass)) + .map(i -> (DeclaredType) i) + .collect(Collectors.toList()); + } + return result; + } +} diff --git a/operator-framework/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/operator-framework/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 0000000000..82f7e04aea --- /dev/null +++ b/operator-framework/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +io.javaoperatorsdk.operator.config.runtime.ControllerConfigurationAnnotationProcessor \ No newline at end of file diff --git a/operator-framework/src/test/crd/test.crd b/operator-framework/src/test/crd/test.crd new file mode 100644 index 0000000000..ac2f5d31b1 --- /dev/null +++ b/operator-framework/src/test/crd/test.crd @@ -0,0 +1,19 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: externals.crd.example +spec: + group: crd.example + names: + kind: External + singular: external + plural: externals + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + type: "object" + served: true + storage: true diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/ConcurrencyIT.java b/operator-framework/src/test/java/com/github/containersolutions/operator/ConcurrencyIT.java deleted file mode 100644 index c75b8b5891..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/ConcurrencyIT.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.github.containersolutions.operator; - -import com.github.containersolutions.operator.sample.TestCustomResource; -import io.fabric8.kubernetes.api.model.ConfigMap; -import org.awaitility.Awaitility; -import org.junit.jupiter.api.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -import static com.github.containersolutions.operator.IntegrationTestSupport.TEST_NAMESPACE; -import static org.assertj.core.api.Assertions.assertThat; - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -public class ConcurrencyIT { - - public static final int NUMBER_OF_RESOURCES_CREATED = 50; - public static final int NUMBER_OF_RESOURCES_DELETED = 30; - public static final int NUMBER_OF_RESOURCES_UPDATED = 20; - private static final Logger log = LoggerFactory.getLogger(ConcurrencyIT.class); - public static final String UPDATED_SUFFIX = "_updated"; - private IntegrationTestSupport integrationTest = new IntegrationTestSupport(); - - @BeforeAll - public void setup() { - integrationTest.initialize(); - } - - @BeforeEach - public void cleanup() { - integrationTest.cleanup(); - } - - @AfterAll - public void teardown() { - integrationTest.teardown(); - } - - @Test - public void manyResourcesGetCreatedUpdatedAndDeleted() throws InterruptedException { - log.info("Adding new resources."); - for (int i = 0; i < NUMBER_OF_RESOURCES_CREATED; i++) { - TestCustomResource tcr = integrationTest.createTestCustomResource(String.valueOf(i)); - integrationTest.getCrOperations().inNamespace(TEST_NAMESPACE).create(tcr); - } - - Awaitility.await().atMost(1, TimeUnit.MINUTES) - .untilAsserted(() -> { - List items = integrationTest.getK8sClient().configMaps() - .inNamespace(TEST_NAMESPACE) - .list().getItems(); - assertThat(items).hasSize(NUMBER_OF_RESOURCES_CREATED); - }); - - log.info("Updating resources."); - // update some resources - for (int i = 0; i < NUMBER_OF_RESOURCES_UPDATED; i++) { - TestCustomResource tcr = integrationTest.createTestCustomResource(String.valueOf(i)); - tcr.getSpec().setValue(i + UPDATED_SUFFIX); - integrationTest.getCrOperations().inNamespace(TEST_NAMESPACE).createOrReplace(tcr); - } - // sleep to make some variability to the test, so some updates are not executed before delete - Thread.sleep(300); - - log.info("Deleting resources."); - // deleting some resources - for (int i = 0; i < NUMBER_OF_RESOURCES_DELETED; i++) { - TestCustomResource tcr = integrationTest.createTestCustomResource(String.valueOf(i)); - integrationTest.getCrOperations().inNamespace(TEST_NAMESPACE).delete(tcr); - } - - Awaitility.await().atMost(1, TimeUnit.MINUTES) - .untilAsserted(() -> { - List items = integrationTest.getK8sClient().configMaps() - .inNamespace(TEST_NAMESPACE) - .list().getItems(); - assertThat(items).hasSize(NUMBER_OF_RESOURCES_CREATED - NUMBER_OF_RESOURCES_DELETED); - - List crs = integrationTest.getCrOperations() - .inNamespace(TEST_NAMESPACE) - .list().getItems(); - assertThat(crs).hasSize(NUMBER_OF_RESOURCES_CREATED - NUMBER_OF_RESOURCES_DELETED); - }); - } - - -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/ControllerExecutionIT.java b/operator-framework/src/test/java/com/github/containersolutions/operator/ControllerExecutionIT.java deleted file mode 100644 index 1e7768f33c..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/ControllerExecutionIT.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.github.containersolutions.operator; - -import com.github.containersolutions.operator.sample.TestCustomResource; -import com.github.containersolutions.operator.sample.TestCustomResourceSpec; -import io.fabric8.kubernetes.api.model.ConfigMap; -import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; -import org.junit.jupiter.api.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.TimeUnit; - -import static com.github.containersolutions.operator.IntegrationTestSupport.TEST_NAMESPACE; -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -public class ControllerExecutionIT { - - private final static Logger log = LoggerFactory.getLogger(ControllerExecutionIT.class); - private IntegrationTestSupport integrationTestSupport = new IntegrationTestSupport(); - - @BeforeAll - public void setup() { - integrationTestSupport.initialize(); - } - - @BeforeEach - public void cleanup() { - integrationTestSupport.cleanup(); - } - - @AfterAll - public void teardown() { - integrationTestSupport.teardown(); - } - - @Test - public void configMapGetsCreatedForTestCustomResource() { - TestCustomResource resource = new TestCustomResource(); - resource.setMetadata(new ObjectMetaBuilder() - .withName("test-custom-resource") - .withNamespace(TEST_NAMESPACE) - .build()); - resource.setKind("CustomService"); - resource.setSpec(new TestCustomResourceSpec()); - resource.getSpec().setConfigMapName("test-config-map"); - resource.getSpec().setKey("test-key"); - resource.getSpec().setValue("test-value"); - integrationTestSupport.getCrOperations().inNamespace(TEST_NAMESPACE).create(resource); - - await("configmap created").atMost(5, TimeUnit.SECONDS) - .untilAsserted(() -> { - ConfigMap configMap = integrationTestSupport.getK8sClient().configMaps().inNamespace(TEST_NAMESPACE) - .withName("test-config-map").get(); - assertThat(configMap).isNotNull(); - assertThat(configMap.getData().get("test-key")).isEqualTo("test-value"); - }); - await("cr status updated").atMost(5, TimeUnit.SECONDS) - .untilAsserted(() -> { - TestCustomResource cr = integrationTestSupport.getCrOperations().inNamespace(TEST_NAMESPACE).withName("test-custom-resource").get(); - assertThat(cr).isNotNull(); - assertThat(cr.getStatus()).isNotNull(); - assertThat(cr.getStatus().getConfigMapStatus()).isEqualTo("ConfigMap Ready"); - }); - } -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/ControllerUtilsTest.java b/operator-framework/src/test/java/com/github/containersolutions/operator/ControllerUtilsTest.java deleted file mode 100644 index 0913378bee..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/ControllerUtilsTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.containersolutions.operator; - -import com.github.containersolutions.operator.sample.TestCustomResource; -import com.github.containersolutions.operator.sample.TestCustomResourceController; -import com.github.containersolutions.operator.sample.TestCustomResourceDoneable; -import com.github.containersolutions.operator.sample.TestCustomResourceList; -import org.junit.jupiter.api.Test; - -import static com.github.containersolutions.operator.api.Controller.DEFAULT_FINALIZER; -import static com.github.containersolutions.operator.sample.TestCustomResourceController.CRD_NAME; -import static org.junit.jupiter.api.Assertions.assertEquals; - -class ControllerUtilsTest { - - @Test - public void returnsValuesFromControllerAnnotationFinalizer() { - assertEquals(DEFAULT_FINALIZER, ControllerUtils.getDefaultFinalizer(new TestCustomResourceController(null))); - assertEquals(TestCustomResource.class, ControllerUtils.getCustomResourceClass(new TestCustomResourceController(null))); - assertEquals(TestCustomResourceDoneable.class, ControllerUtils.getCustomResourceDonebaleClass(new TestCustomResourceController(null))); - assertEquals(TestCustomResourceList.class, ControllerUtils.getCustomResourceListClass(new TestCustomResourceController(null))); - assertEquals(CRD_NAME, ControllerUtils.getCrdName(new TestCustomResourceController(null))); - } - -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/EventDispatcherTest.java b/operator-framework/src/test/java/com/github/containersolutions/operator/EventDispatcherTest.java deleted file mode 100644 index 7924708fff..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/EventDispatcherTest.java +++ /dev/null @@ -1,158 +0,0 @@ -package com.github.containersolutions.operator; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.containersolutions.operator.api.Controller; -import com.github.containersolutions.operator.api.ResourceController; -import com.github.containersolutions.operator.processing.EventDispatcher; -import com.github.containersolutions.operator.sample.TestCustomResource; -import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; -import io.fabric8.kubernetes.client.CustomResource; -import io.fabric8.kubernetes.client.Watcher; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentMatchers; - -import java.util.HashMap; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.*; - -class EventDispatcherTest { - - private CustomResource testCustomResource; - private EventDispatcher eventDispatcher; - private ResourceController resourceController = mock(ResourceController.class); - private EventDispatcher.CustomResourceReplaceFacade customResourceReplaceFacade = mock(EventDispatcher.CustomResourceReplaceFacade.class); - - - @BeforeEach - void setup() { - eventDispatcher = new EventDispatcher(resourceController, - Controller.DEFAULT_FINALIZER, customResourceReplaceFacade); - - testCustomResource = getResource(); - - when(resourceController.createOrUpdateResource(eq(testCustomResource))).thenReturn(Optional.of(testCustomResource)); - when(resourceController.deleteResource(eq(testCustomResource))).thenReturn(true); - when(customResourceReplaceFacade.replaceWithLock(any())).thenReturn(null); - } - - @Test - void callCreateOrUpdateOnNewResource() { - eventDispatcher.handleEvent(Watcher.Action.ADDED, testCustomResource); - verify(resourceController, times(1)).createOrUpdateResource(ArgumentMatchers.eq(testCustomResource)); - } - - @Test - void callCreateOrUpdateOnModifiedResource() { - eventDispatcher.handleEvent(Watcher.Action.MODIFIED, testCustomResource); - verify(resourceController, times(1)).createOrUpdateResource(ArgumentMatchers.eq(testCustomResource)); - } - - @Test - void adsDefaultFinalizerOnCreateIfNotThere() { - eventDispatcher.handleEvent(Watcher.Action.MODIFIED, testCustomResource); - verify(resourceController, times(1)) - .createOrUpdateResource(argThat(testCustomResource -> - testCustomResource.getMetadata().getFinalizers().contains(Controller.DEFAULT_FINALIZER))); - } - - @Test - void callsDeleteIfObjectHasFinalizerAndMarkedForDelete() { - testCustomResource.getMetadata().setDeletionTimestamp("2019-8-10"); - testCustomResource.getMetadata().getFinalizers().add(Controller.DEFAULT_FINALIZER); - - eventDispatcher.handleEvent(Watcher.Action.MODIFIED, testCustomResource); - - verify(resourceController, times(1)).deleteResource(eq(testCustomResource)); - } - - /** - * Note that there could be more finalizers. Out of our control. - */ - @Test - void callDeleteOnControllerIfMarkedForDeletionButThereIsNoDefaultFinalizer() { - markForDeletion(testCustomResource); - - eventDispatcher.handleEvent(Watcher.Action.MODIFIED, testCustomResource); - - verify(resourceController).deleteResource(eq(testCustomResource)); - } - - @Test - void removesDefaultFinalizerOnDelete() { - markForDeletion(testCustomResource); - testCustomResource.getMetadata().getFinalizers().add(Controller.DEFAULT_FINALIZER); - - eventDispatcher.handleEvent(Watcher.Action.MODIFIED, testCustomResource); - - assertEquals(0, testCustomResource.getMetadata().getFinalizers().size()); - verify(customResourceReplaceFacade, times(1)).replaceWithLock(any()); - } - - @Test - void doesNotRemovesTheFinalizerIfTheDeleteMethodRemovesFalse() { - when(resourceController.deleteResource(eq(testCustomResource))).thenReturn(false); - markForDeletion(testCustomResource); - testCustomResource.getMetadata().getFinalizers().add(Controller.DEFAULT_FINALIZER); - - eventDispatcher.handleEvent(Watcher.Action.MODIFIED, testCustomResource); - - assertEquals(1, testCustomResource.getMetadata().getFinalizers().size()); - verify(customResourceReplaceFacade, never()).replaceWithLock(any()); - } - - @Test - void doesNotUpdateTheResourceIfEmptyOptionalReturned() { - testCustomResource.getMetadata().getFinalizers().add(Controller.DEFAULT_FINALIZER); - when(resourceController.createOrUpdateResource(eq(testCustomResource))).thenReturn(Optional.empty()); - - eventDispatcher.handleEvent(Watcher.Action.MODIFIED, testCustomResource); - verify(customResourceReplaceFacade, never()).replaceWithLock(any()); - } - - @Test - void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() { - when(resourceController.createOrUpdateResource(eq(testCustomResource))).thenReturn(Optional.empty()); - - eventDispatcher.handleEvent(Watcher.Action.MODIFIED, testCustomResource); - - assertEquals(1, testCustomResource.getMetadata().getFinalizers().size()); - verify(customResourceReplaceFacade, times(1)).replaceWithLock(any()); - } - - @Test - void doesNotAddFinalizerIfOptionalIsReturnedButMarkedForDeletion() { - markForDeletion(testCustomResource); - when(resourceController.createOrUpdateResource(eq(testCustomResource))).thenReturn(Optional.empty()); - - eventDispatcher.handleEvent(Watcher.Action.MODIFIED, testCustomResource); - - assertEquals(0, testCustomResource.getMetadata().getFinalizers().size()); - verify(customResourceReplaceFacade, never()).replaceWithLock(any()); - } - - private void markForDeletion(CustomResource customResource) { - customResource.getMetadata().setDeletionTimestamp("2019-8-10"); - } - - CustomResource getResource() { - TestCustomResource resource = new TestCustomResource(); - resource.setMetadata(new ObjectMetaBuilder() - .withClusterName("clusterName") - .withCreationTimestamp("creationTimestamp") - .withDeletionGracePeriodSeconds(10L) - .withGeneration(10L) - .withName("name") - .withNamespace("namespace") - .withResourceVersion("resourceVersion") - .withSelfLink("selfLink") - .withUid("uid").build()); - return resource; - } - - HashMap getRawResource() { - return new ObjectMapper().convertValue(getResource(), HashMap.class); - } -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/EventSchedulerTest.java b/operator-framework/src/test/java/com/github/containersolutions/operator/EventSchedulerTest.java deleted file mode 100644 index 4385a5b0df..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/EventSchedulerTest.java +++ /dev/null @@ -1,267 +0,0 @@ -package com.github.containersolutions.operator; - -import com.github.containersolutions.operator.processing.EventDispatcher; -import com.github.containersolutions.operator.processing.EventScheduler; -import com.github.containersolutions.operator.processing.retry.GenericRetry; -import com.github.containersolutions.operator.sample.TestCustomResource; -import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; -import io.fabric8.kubernetes.client.CustomResource; -import io.fabric8.kubernetes.client.Watcher; -import org.assertj.core.api.Condition; -import org.junit.jupiter.api.Test; -import org.mockito.invocation.InvocationOnMock; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CompletableFuture; - -import static com.github.containersolutions.operator.processing.retry.GenericRetry.*; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.atIndex; -import static org.mockito.Mockito.*; - -class EventSchedulerTest { - - public static final int INVOCATION_DURATION = 80; - @SuppressWarnings("unchecked") - private EventDispatcher eventDispatcher = mock(EventDispatcher.class); - - private EventScheduler eventScheduler = new EventScheduler(eventDispatcher, GenericRetry.defaultLimitedExponentialRetry()); - - private List eventProcessingList = Collections.synchronizedList(new ArrayList<>()); - - - @Test - public void schedulesEvent() { - normalDispatcherExecution(); - CustomResource resource = sampleResource(); - - eventScheduler.eventReceived(Watcher.Action.MODIFIED, resource); - - waitMinimalTimeForExecution(); - verify(eventDispatcher, times(1)).handleEvent(Watcher.Action.MODIFIED, resource); - assertThat(eventProcessingList).hasSize(1); - } - - @Test - public void eventsAreNotExecutedConcurrentlyForSameResource() throws InterruptedException { - normalDispatcherExecution(); - CustomResource resource1 = sampleResource(); - CustomResource resource2 = sampleResource(); - resource2.getMetadata().setResourceVersion("2"); - - CompletableFuture.runAsync(() -> eventScheduler.eventReceived(Watcher.Action.MODIFIED, resource1)); - Thread.sleep(50); - CompletableFuture.runAsync(() -> eventScheduler.eventReceived(Watcher.Action.MODIFIED, resource2)); - - waitTimeForExecution(2); - assertThat(eventProcessingList).hasSize(2) - .matches(list -> eventProcessingList.get(0).getCustomResource().getMetadata().getResourceVersion().equals("1") && - eventProcessingList.get(1).getCustomResource().getMetadata().getResourceVersion().equals("2"), - "Events processed in correct order") - .matches(list -> - eventProcessingList.get(0).getEndTime().isBefore(eventProcessingList.get(1).startTime), - "Start time of event 2 is after end time of event 1"); - } - - @Test - public void retriesEventsWithErrors() { - doAnswer(this::exceptionInExecution) - .doAnswer(this::normalExecution) - .when(eventDispatcher) - .handleEvent(any(Watcher.Action.class), any(CustomResource.class)); - - CustomResource resource = sampleResource(); - - eventScheduler.eventReceived(Watcher.Action.MODIFIED, resource); - waitTimeForExecution(2, 1); - - assertThat(eventProcessingList) - .hasSize(2) - .has(new Condition<>(e -> e.getException() != null, ""), atIndex(0)) - .has(new Condition<>(e -> e.getException() == null, ""), atIndex(1)); - } - - - @Test - public void discardsEventIfThereWereANewerVersionProcessedBefore() throws InterruptedException { - normalDispatcherExecution(); - CustomResource resource1 = sampleResource(); - CustomResource resource2 = sampleResource(); - resource2.getMetadata().setResourceVersion("2"); - - CompletableFuture.runAsync(() -> eventScheduler.eventReceived(Watcher.Action.MODIFIED, resource2)); - Thread.sleep(50); - CompletableFuture.runAsync(() -> eventScheduler.eventReceived(Watcher.Action.MODIFIED, resource1)); - - waitTimeForExecution(2); - - assertThat(eventProcessingList).hasSize(1).has(new Condition<>(e -> e.getCustomResource().getMetadata().getResourceVersion().equals("2"), - "Just handles event that arrived first"), atIndex(0)); - } - - @Test - public void schedulesEventIfOlderVersionIsAlreadyUnderProcessing() { - normalDispatcherExecution(); - CustomResource resource1 = sampleResource(); - CustomResource resource2 = sampleResource(); - resource2.getMetadata().setResourceVersion("2"); - - doAnswer(invocation -> { - Object[] args = invocation.getArguments(); - LocalDateTime start = LocalDateTime.now(); - CompletableFuture.runAsync(() -> eventScheduler.eventReceived(Watcher.Action.MODIFIED, resource2)); - Thread.sleep(INVOCATION_DURATION); - LocalDateTime end = LocalDateTime.now(); - eventProcessingList.add(new EventProcessingDetail((Watcher.Action) args[0], start, end, (CustomResource) args[1])); - return null; - }).doAnswer(this::normalExecution).when(eventDispatcher).handleEvent(any(Watcher.Action.class), any(CustomResource.class)); - - CompletableFuture.runAsync(() -> eventScheduler.eventReceived(Watcher.Action.MODIFIED, resource1)); - - waitTimeForExecution(2); - assertThat(eventProcessingList).hasSize(2) - .matches(list -> eventProcessingList.get(0).getCustomResource().getMetadata().getResourceVersion().equals("1") && - eventProcessingList.get(1).getCustomResource().getMetadata().getResourceVersion().equals("2"), - "Events processed in correct order") - .matches(list -> - eventProcessingList.get(0).getEndTime().isBefore(eventProcessingList.get(1).startTime), - "Start time of event 2 is after end time of event 1"); - } - - @Test - public void numberOfRetriesIsLimited() { - doAnswer(this::exceptionInExecution).when(eventDispatcher).handleEvent(any(Watcher.Action.class), any(CustomResource.class)); - - CompletableFuture.runAsync(() -> eventScheduler.eventReceived(Watcher.Action.MODIFIED, sampleResource())); - - waitTimeForExecution(1, DEFAULT_MAX_ATTEMPTS + 2); - assertThat(eventProcessingList).hasSize(DEFAULT_MAX_ATTEMPTS); - } - - public void normalDispatcherExecution() { - doAnswer(this::normalExecution).when(eventDispatcher).handleEvent(any(Watcher.Action.class), any(CustomResource.class)); - } - - private Object normalExecution(InvocationOnMock invocation) { - try { - Object[] args = invocation.getArguments(); - LocalDateTime start = LocalDateTime.now(); - Thread.sleep(INVOCATION_DURATION); - LocalDateTime end = LocalDateTime.now(); - eventProcessingList.add(new EventProcessingDetail((Watcher.Action) args[0], start, end, (CustomResource) args[1])); - return null; - } catch (InterruptedException e) { - throw new IllegalStateException(e); - } - } - - - private Object exceptionInExecution(InvocationOnMock invocation) { - try { - Object[] args = invocation.getArguments(); - LocalDateTime start = LocalDateTime.now(); - Thread.sleep(INVOCATION_DURATION); - LocalDateTime end = LocalDateTime.now(); - IllegalStateException exception = new IllegalStateException("Exception thrown for testing purposes"); - eventProcessingList.add(new EventProcessingDetail((Watcher.Action) args[0], start, end, (CustomResource) args[1], exception)); - throw exception; - } catch (InterruptedException e) { - throw new IllegalStateException(e); - } - } - - private void waitMinimalTimeForExecution() { - waitTimeForExecution(1); - } - - private void waitTimeForExecution(int numberOfEvents) { - waitTimeForExecution(numberOfEvents, 0); - } - - private void waitTimeForExecution(int numberOfEvents, int retries) { - try { - Thread.sleep((long) (200 + ((INVOCATION_DURATION + 30) * numberOfEvents) + (retries * (INVOCATION_DURATION + 100)) + - (Math.pow(DEFAULT_MULTIPLIER, retries) * (DEFAULT_INITIAL_INTERVAL + 100)))); - } catch (InterruptedException e) { - throw new IllegalStateException(e); - } - } - - CustomResource sampleResource() { - TestCustomResource resource = new TestCustomResource(); - resource.setMetadata(new ObjectMetaBuilder() - .withCreationTimestamp("creationTimestamp") - .withDeletionGracePeriodSeconds(10L) - .withGeneration(10L) - .withName("name") - .withNamespace("namespace") - .withResourceVersion("1") - .withSelfLink("selfLink") - .withUid("uid").build()); - return resource; - } - - private static class EventProcessingDetail { - private Watcher.Action action; - private LocalDateTime startTime; - private LocalDateTime endTime; - private CustomResource customResource; - private Exception exception; - - public EventProcessingDetail(Watcher.Action action, LocalDateTime startTime, LocalDateTime endTime, CustomResource customResource, Exception exception) { - this.action = action; - this.startTime = startTime; - this.endTime = endTime; - this.customResource = customResource; - this.exception = exception; - } - - public EventProcessingDetail(Watcher.Action action, LocalDateTime startTime, LocalDateTime endTime, - CustomResource customResource) { - this(action, startTime, endTime, customResource, null); - } - - public LocalDateTime getStartTime() { - return startTime; - } - - public EventProcessingDetail setStartTime(LocalDateTime startTime) { - this.startTime = startTime; - return this; - } - - public LocalDateTime getEndTime() { - return endTime; - } - - public EventProcessingDetail setEndTime(LocalDateTime endTime) { - this.endTime = endTime; - return this; - } - - public CustomResource getCustomResource() { - return customResource; - } - - public EventProcessingDetail setCustomResource(CustomResource customResource) { - this.customResource = customResource; - return this; - } - - public Watcher.Action getAction() { - return action; - } - - public EventProcessingDetail setAction(Watcher.Action action) { - this.action = action; - return this; - } - - public Exception getException() { - return exception; - } - } -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/IntegrationTestSupport.java b/operator-framework/src/test/java/com/github/containersolutions/operator/IntegrationTestSupport.java deleted file mode 100644 index 7b72215461..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/IntegrationTestSupport.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.github.containersolutions.operator; - -import com.github.containersolutions.operator.sample.*; -import io.fabric8.kubernetes.api.model.NamespaceBuilder; -import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; -import io.fabric8.kubernetes.api.model.apiextensions.CustomResourceDefinition; -import io.fabric8.kubernetes.client.DefaultKubernetesClient; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.dsl.MixedOperation; -import io.fabric8.kubernetes.client.dsl.Resource; -import io.fabric8.kubernetes.client.utils.Serialization; -import io.fabric8.kubernetes.internal.KubernetesDeserializer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.io.InputStream; -import java.util.concurrent.TimeUnit; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -public class IntegrationTestSupport { - - public static final String TEST_NAMESPACE = "java-operator-sdk-int-test"; - public static final String TEST_CUSTOM_RESOURCE_PREFIX = "test-custom-resource-"; - private final static Logger log = LoggerFactory.getLogger(IntegrationTestSupport.class); - private KubernetesClient k8sClient; - private MixedOperation> crOperations; - private Operator operator; - - - public void initialize() { - k8sClient = new DefaultKubernetesClient(); - - log.info("Running integration test in namespace " + TEST_NAMESPACE); - - CustomResourceDefinition crd = loadYaml(CustomResourceDefinition.class, "test-crd.yaml"); - k8sClient.customResourceDefinitions().createOrReplace(crd); - - if (k8sClient.namespaces().withName(TEST_NAMESPACE).get() == null) { - k8sClient.namespaces().create(new NamespaceBuilder() - .withMetadata(new ObjectMetaBuilder().withName(TEST_NAMESPACE).build()).build()); - } - operator = new Operator(k8sClient); - operator.registerController(new TestCustomResourceController(k8sClient), TEST_NAMESPACE); - } - - public void cleanup() { - CustomResourceDefinition crd = loadYaml(CustomResourceDefinition.class, "test-crd.yaml"); - k8sClient.customResourceDefinitions().createOrReplace(crd); - KubernetesDeserializer.registerCustomKind(crd.getApiVersion(), crd.getKind(), TestCustomResource.class); - - crOperations = k8sClient.customResources(crd, TestCustomResource.class, TestCustomResourceList.class, TestCustomResourceDoneable.class); - crOperations.inNamespace(TEST_NAMESPACE).delete(crOperations.list().getItems()); - //we depend on the actual operator from the startup to handle the finalizers and clean up - //resources from previous test runs - - await("all CRs cleaned up").atMost(60, TimeUnit.SECONDS) - .untilAsserted(() -> { - assertThat(crOperations.inNamespace(TEST_NAMESPACE).list().getItems()).isEmpty(); - - }); - - k8sClient.configMaps().inNamespace(TEST_NAMESPACE) - .withLabel("managedBy", TestCustomResourceController.class.getSimpleName()) - .delete(); - - await("all config maps cleaned up").atMost(60, TimeUnit.SECONDS) - .untilAsserted(() -> { - assertThat(k8sClient.configMaps().inNamespace(TEST_NAMESPACE).list().getItems()).isEmpty(); - }); - - log.info("Cleaned up namespace " + TEST_NAMESPACE); - } - - public void teardown() { - operator.stop(); - } - - private T loadYaml(Class clazz, String yaml) { - try (InputStream is = getClass().getResourceAsStream(yaml)) { - return Serialization.unmarshal(is, clazz); - } catch (IOException ex) { - throw new IllegalStateException("Cannot find yaml on classpath: " + yaml); - } - } - - public TestCustomResource createTestCustomResource(String id) { - TestCustomResource resource = new TestCustomResource(); - resource.setMetadata(new ObjectMetaBuilder() - .withName(TEST_CUSTOM_RESOURCE_PREFIX + id) - .withNamespace(TEST_NAMESPACE) - .build()); - resource.setKind("CustomService"); - resource.setSpec(new TestCustomResourceSpec()); - resource.getSpec().setConfigMapName("test-config-map-" + id); - resource.getSpec().setKey("test-key"); - resource.getSpec().setValue(id); - return resource; - } - - public KubernetesClient getK8sClient() { - return k8sClient; - } - - public MixedOperation> getCrOperations() { - return crOperations; - } - - public Operator getOperator() { - return operator; - } -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/processing/retry/GenericRetryExecutionTest.java b/operator-framework/src/test/java/com/github/containersolutions/operator/processing/retry/GenericRetryExecutionTest.java deleted file mode 100644 index c7c2f6f43f..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/processing/retry/GenericRetryExecutionTest.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.github.containersolutions.operator.processing.retry; - -import org.junit.jupiter.api.Test; - -import java.util.Optional; - -import static com.github.containersolutions.operator.processing.retry.GenericRetry.DEFAULT_INITIAL_INTERVAL; -import static com.github.containersolutions.operator.processing.retry.GenericRetry.DEFAULT_MULTIPLIER; -import static org.assertj.core.api.Assertions.assertThat; - -public class GenericRetryExecutionTest { - - @Test - public void forFirstBackOffAlwaysReturnsZero() { - assertThat(getDefaultRetryExecution().nextDelay().get()).isEqualTo(0); - } - - @Test - public void delayIsMultipliedEveryNextDelayCall() { - RetryExecution retryExecution = getDefaultRetryExecution(); - - Optional res = callNextDelayNTimes(retryExecution, 2); - assertThat(res.get()).isEqualTo(DEFAULT_INITIAL_INTERVAL); - - res = retryExecution.nextDelay(); - assertThat(res.get()).isEqualTo((long) (DEFAULT_INITIAL_INTERVAL * DEFAULT_MULTIPLIER)); - - res = retryExecution.nextDelay(); - assertThat(res.get()).isEqualTo((long) (DEFAULT_INITIAL_INTERVAL * DEFAULT_MULTIPLIER * DEFAULT_MULTIPLIER)); - } - - @Test - public void noNextDelayIfMaxElapsedTimeReached() { - RetryExecution retryExecution = GenericRetry.defaultLimitedExponentialRetry() - .setMaxElapsedTime(5000) - .setInitialInterval(2000) - .setIntervalMultiplier(1) - .initExecution(); - Optional res = callNextDelayNTimes(retryExecution, 3); - assertThat(res).isNotEmpty(); - - res = retryExecution.nextDelay(); - assertThat(res).isEmpty(); - } - - @Test - public void noNextDelayIfMaxAttemptLimitReached() { - RetryExecution retryExecution = GenericRetry.defaultLimitedExponentialRetry().setMaxAttempts(3).initExecution(); - Optional res = callNextDelayNTimes(retryExecution, 3); - assertThat(res).isNotEmpty(); - - res = retryExecution.nextDelay(); - assertThat(res).isEmpty(); - } - - @Test - public void canLimitMaxIntervalLength() { - RetryExecution retryExecution = GenericRetry.defaultLimitedExponentialRetry() - .setInitialInterval(2000) - .setMaxInterval(4500) - .setIntervalMultiplier(2) - .initExecution(); - - Optional res = callNextDelayNTimes(retryExecution, 4); - - assertThat(res.get()).isEqualTo(4500); - } - - @Test - public void supportsNoRetry() { - RetryExecution retryExecution = GenericRetry.noRetry().initExecution(); - assertThat(retryExecution.nextDelay().get()).isZero(); - assertThat(retryExecution.nextDelay()).isEmpty(); - } - - private RetryExecution getDefaultRetryExecution() { - return GenericRetry.defaultLimitedExponentialRetry().initExecution(); - } - - public Optional callNextDelayNTimes(RetryExecution retryExecution, int n) { - for (int i = 0; i < n - 1; i++) { - retryExecution.nextDelay(); - } - return retryExecution.nextDelay(); - } - -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResource.java b/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResource.java deleted file mode 100644 index a24e4e4757..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResource.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.client.CustomResource; - -public class TestCustomResource extends CustomResource { - - private TestCustomResourceSpec spec; - - private TestCustomResourceStatus status; - - public TestCustomResourceSpec getSpec() { - return spec; - } - - public void setSpec(TestCustomResourceSpec spec) { - this.spec = spec; - } - - public TestCustomResourceStatus getStatus() { - return status; - } - - public void setStatus(TestCustomResourceStatus status) { - this.status = status; - } - - @Override - public String toString() { - return "TestCustomResource{" + - "spec=" + spec + - ", status=" + status + - ", extendedFrom=" + super.toString() + - '}'; - } -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceController.java b/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceController.java deleted file mode 100644 index 94836c011a..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceController.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import com.github.containersolutions.operator.api.Controller; -import com.github.containersolutions.operator.api.ResourceController; -import io.fabric8.kubernetes.api.model.ConfigMap; -import io.fabric8.kubernetes.api.model.ConfigMapBuilder; -import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; -import io.fabric8.kubernetes.client.KubernetesClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -@Controller( - crdName = TestCustomResourceController.CRD_NAME, - customResourceClass = TestCustomResource.class, - customResourceListClass = TestCustomResourceList.class, - customResourceDoneableClass = TestCustomResourceDoneable.class) -public class TestCustomResourceController implements ResourceController { - - private static final Logger log = LoggerFactory.getLogger(TestCustomResourceController.class); - - public static final String CRD_NAME = "customservices.sample.javaoperatorsdk"; - - private final KubernetesClient kubernetesClient; - - public TestCustomResourceController(KubernetesClient kubernetesClient) { - this.kubernetesClient = kubernetesClient; - } - - @Override - public boolean deleteResource(TestCustomResource resource) { - kubernetesClient.configMaps().inNamespace(resource.getMetadata().getNamespace()) - .withName(resource.getSpec().getConfigMapName()).delete(); - log.info("Deleting config map with name: {} for resource: {}", resource.getSpec().getConfigMapName(), resource.getMetadata().getName()); - return true; - } - - @Override - public Optional createOrUpdateResource(TestCustomResource resource) { - ConfigMap existingConfigMap = kubernetesClient - .configMaps().inNamespace(resource.getMetadata().getNamespace()) - .withName(resource.getSpec().getConfigMapName()).get(); - - if (existingConfigMap != null) { - existingConfigMap.setData(configMapData(resource)); -// existingConfigMap.getMetadata().setResourceVersion(null); - kubernetesClient.configMaps().inNamespace(resource.getMetadata().getNamespace()) - .withName(existingConfigMap.getMetadata().getName()).createOrReplace(existingConfigMap); - } else { - Map labels = new HashMap<>(); - labels.put("managedBy", TestCustomResourceController.class.getSimpleName()); - ConfigMap newConfigMap = new ConfigMapBuilder() - .withMetadata(new ObjectMetaBuilder() - .withName(resource.getSpec().getConfigMapName()) - .withNamespace(resource.getMetadata().getNamespace()) - .withLabels(labels) - .build()) - .withData(configMapData(resource)).build(); - kubernetesClient.configMaps().inNamespace(resource.getMetadata().getNamespace()) - .createOrReplace(newConfigMap); - } - - if (resource.getStatus() == null) { - resource.setStatus(new TestCustomResourceStatus()); - } - resource.getStatus().setConfigMapStatus("ConfigMap Ready"); - return Optional.of(resource); - } - - private Map configMapData(TestCustomResource resource) { - Map data = new HashMap<>(); - data.put(resource.getSpec().getKey(), resource.getSpec().getValue()); - return data; - } -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceDoneable.java b/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceDoneable.java deleted file mode 100644 index feed2181a7..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceDoneable.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.api.builder.Function; -import io.fabric8.kubernetes.client.CustomResourceDoneable; - -public class TestCustomResourceDoneable extends CustomResourceDoneable { - - public TestCustomResourceDoneable(TestCustomResource resource, Function function) { - super(resource, function); - } -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceList.java b/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceList.java deleted file mode 100644 index 49901d4eb1..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceList.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.client.CustomResourceList; - -public class TestCustomResourceList extends CustomResourceList { -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceSpec.java b/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceSpec.java deleted file mode 100644 index 31a060733f..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceSpec.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.github.containersolutions.operator.sample; - -public class TestCustomResourceSpec { - - private String configMapName; - - private String key; - - private String value; - - public String getConfigMapName() { - return configMapName; - } - - public void setConfigMapName(String configMapName) { - this.configMapName = configMapName; - } - - public String getKey() { - return key; - } - - public void setKey(String key) { - this.key = key; - } - - public String getValue() { - return value; - } - - public void setValue(String value) { - this.value = value; - } - - @Override - public String toString() { - return "TestCustomResourceSpec{" + - "configMapName='" + configMapName + '\'' + - ", key='" + key + '\'' + - ", value='" + value + '\'' + - '}'; - } -} diff --git a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceStatus.java b/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceStatus.java deleted file mode 100644 index 686804232c..0000000000 --- a/operator-framework/src/test/java/com/github/containersolutions/operator/sample/TestCustomResourceStatus.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.containersolutions.operator.sample; - -public class TestCustomResourceStatus { - - private String configMapStatus; - - public String getConfigMapStatus() { - return configMapStatus; - } - - public void setConfigMapStatus(String configMapStatus) { - this.configMapStatus = configMapStatus; - } - - @Override - public String toString() { - return "TestCustomResourceStatus{" + - "configMapStatus='" + configMapStatus + '\'' + - '}'; - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java new file mode 100644 index 0000000000..9153ae4ff5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CRDMappingInTestExtensionIT.java @@ -0,0 +1,73 @@ +package io.javaoperatorsdk.operator; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Kind; +import io.fabric8.kubernetes.model.annotation.Version; +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.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class CRDMappingInTestExtensionIT { + private final KubernetesClient client = new KubernetesClientBuilder().build(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new TestReconciler()) + .withAdditionalCRD("src/test/resources/crd/test.crd", "src/test/crd/test.crd") + .build(); + + @Test + void correctlyAppliesManuallySpecifiedCRD() { + final var crdClient = client.apiextensions().v1().customResourceDefinitions(); + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted( + () -> { + final var actual = crdClient.withName("tests.crd.example").get(); + assertThat(actual).isNotNull(); + assertThat( + actual + .getSpec() + .getVersions() + .get(0) + .getSchema() + .getOpenAPIV3Schema() + .getProperties() + .containsKey("foo")) + .isTrue(); + }); + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted( + () -> assertThat(crdClient.withName("externals.crd.example").get()).isNotNull()); + } + + @Group("crd.example") + @Version("v1") + @Kind("Test") + private static class TestCR extends CustomResource implements Namespaced {} + + @ControllerConfiguration + private static class TestReconciler implements Reconciler { + @Override + public UpdateControl reconcile(TestCR resource, Context context) + throws Exception { + return null; + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/IntegrationTestConstants.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/IntegrationTestConstants.java new file mode 100644 index 0000000000..a4c3abfad4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/IntegrationTestConstants.java @@ -0,0 +1,6 @@ +package io.javaoperatorsdk.operator; + +public class IntegrationTestConstants { + + public static final int GARBAGE_COLLECTION_TIMEOUT_SECONDS = 60; +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ConcurrencyIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ConcurrencyIT.java new file mode 100644 index 0000000000..80fb09095d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ConcurrencyIT.java @@ -0,0 +1,94 @@ +package io.javaoperatorsdk.operator.baseapi; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.baseapi.simple.TestCustomResource; +import io.javaoperatorsdk.operator.baseapi.simple.TestReconciler; +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; + +class ConcurrencyIT { + public static final int NUMBER_OF_RESOURCES_CREATED = 50; + public static final int NUMBER_OF_RESOURCES_DELETED = 30; + public static final int NUMBER_OF_RESOURCES_UPDATED = 20; + public static final String UPDATED_SUFFIX = "_updated"; + private static final Logger log = LoggerFactory.getLogger(ConcurrencyIT.class); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(new TestReconciler(true)).build(); + + @Test + void manyResourcesGetCreatedUpdatedAndDeleted() throws InterruptedException { + log.info("Creating {} new resources", NUMBER_OF_RESOURCES_CREATED); + for (int i = 0; i < NUMBER_OF_RESOURCES_CREATED; i++) { + TestCustomResource tcr = TestUtils.testCustomResourceWithPrefix(String.valueOf(i)); + operator.resources(TestCustomResource.class).resource(tcr).create(); + } + + await() + .atMost(1, TimeUnit.MINUTES) + .untilAsserted( + () -> { + List items = + operator + .resources(ConfigMap.class) + .withLabel("managedBy", TestReconciler.class.getSimpleName()) + .list() + .getItems(); + assertThat(items).hasSize(NUMBER_OF_RESOURCES_CREATED); + }); + + log.info("Updating {} resources", NUMBER_OF_RESOURCES_UPDATED); + // update some resources + for (int i = 0; i < NUMBER_OF_RESOURCES_UPDATED; i++) { + TestCustomResource tcr = + operator.get(TestCustomResource.class, TestUtils.TEST_CUSTOM_RESOURCE_PREFIX + i); + tcr.getSpec().setValue(i + UPDATED_SUFFIX); + operator.resources(TestCustomResource.class).resource(tcr).createOrReplace(); + } + // sleep for a short time to make variability to the test, so some updates are not + // executed before delete + Thread.sleep(300); + + log.info("Deleting {} resources", NUMBER_OF_RESOURCES_DELETED); + for (int i = 0; i < NUMBER_OF_RESOURCES_DELETED; i++) { + TestCustomResource tcr = TestUtils.testCustomResourceWithPrefix(String.valueOf(i)); + operator.resources(TestCustomResource.class).resource(tcr).delete(); + } + + await() + .atMost(1, TimeUnit.MINUTES) + .untilAsserted( + () -> { + List items = + operator + .resources(ConfigMap.class) + .withLabel("managedBy", TestReconciler.class.getSimpleName()) + .list() + .getItems(); + // reducing configmaps to names only - better for debugging + List itemDescs = + items.stream() + .map(configMap -> configMap.getMetadata().getName()) + .collect(Collectors.toList()); + assertThat(itemDescs) + .hasSize(NUMBER_OF_RESOURCES_CREATED - NUMBER_OF_RESOURCES_DELETED); + + List crs = + operator.resources(TestCustomResource.class).list().getItems(); + assertThat(crs).hasSize(NUMBER_OF_RESOURCES_CREATED - NUMBER_OF_RESOURCES_DELETED); + }); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/InformerErrorHandlerStartIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/InformerErrorHandlerStartIT.java new file mode 100644 index 0000000000..18a107d9b2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/InformerErrorHandlerStartIT.java @@ -0,0 +1,46 @@ +package io.javaoperatorsdk.operator.baseapi; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.client.ConfigBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.javaoperatorsdk.operator.Operator; +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; + +class InformerErrorHandlerStartIT { + /** Test showcases that the operator starts even if there is no access right for some resource. */ + @Test + @Timeout(5) + void operatorStart() { + KubernetesClient client = + new KubernetesClientBuilder() + .withConfig(new ConfigBuilder().withImpersonateUsername("user-with-no-rights").build()) + .build(); + + Operator operator = + new Operator( + o -> + o.withKubernetesClient(client) + .withStopOnInformerErrorDuringStartup(false) + .withCacheSyncTimeout(Duration.ofSeconds(2))); + operator.register(new ConfigMapReconciler()); + operator.start(); + } + + @ControllerConfiguration + public static class ConfigMapReconciler implements Reconciler { + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) + throws Exception { + return UpdateControl.noUpdate(); + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/LeaderElectionPermissionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/LeaderElectionPermissionIT.java new file mode 100644 index 0000000000..51b1f4b3d5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/LeaderElectionPermissionIT.java @@ -0,0 +1,76 @@ +package io.javaoperatorsdk.operator.baseapi; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.rbac.Role; +import io.fabric8.kubernetes.api.model.rbac.RoleBinding; +import io.fabric8.kubernetes.client.ConfigBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration; +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.LeaderElectionManager.NO_PERMISSION_TO_LEASE_RESOURCE_MESSAGE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LeaderElectionPermissionIT { + + KubernetesClient adminClient = new KubernetesClientBuilder().build(); + + @Test + void operatorStopsIfNoLeaderElectionPermission() { + applyRole(); + applyRoleBinding(); + + var client = + new KubernetesClientBuilder() + .withConfig( + new ConfigBuilder().withImpersonateUsername("leader-elector-stop-noaccess").build()) + .build(); + + var operator = + new Operator( + o -> { + o.withKubernetesClient(client); + o.withLeaderElectionConfiguration( + new LeaderElectionConfiguration("lease1", "default")); + o.withStopOnInformerErrorDuringStartup(false); + }); + operator.register(new TestReconciler(), o -> o.settingNamespace("default")); + + OperatorException exception = assertThrows(OperatorException.class, operator::start); + + assertThat(exception.getCause().getMessage()).contains(NO_PERMISSION_TO_LEASE_RESOURCE_MESSAGE); + } + + @ControllerConfiguration + public static class TestReconciler implements Reconciler { + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) + throws Exception { + throw new IllegalStateException("Should not get here"); + } + } + + private void applyRoleBinding() { + var clusterRoleBinding = + ReconcilerUtils.loadYaml( + RoleBinding.class, this.getClass(), "leader-elector-stop-noaccess-role-binding.yaml"); + adminClient.resource(clusterRoleBinding).createOrReplace(); + } + + private void applyRole() { + var role = + ReconcilerUtils.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/builtinresourcecleaner/BuiltInResourceCleanerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerIT.java new file mode 100644 index 0000000000..9d914d080c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerIT.java @@ -0,0 +1,70 @@ +package io.javaoperatorsdk.operator.baseapi.builtinresourcecleaner; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.Service; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.dependent.standalonedependent.StandaloneDependentResourceIT; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class BuiltInResourceCleanerIT { + + private static final Logger log = LoggerFactory.getLogger(BuiltInResourceCleanerIT.class); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new BuiltInResourceCleanerReconciler()) + .build(); + + /** + * Issue is with generation, some built in resources like Pod, Service does not seem to use + * generation. + */ + @Test + void cleanerIsCalledOnBuiltInResource() { + var service = operator.create(testService()); + + await() + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(BuiltInResourceCleanerReconciler.class) + .getReconcileCount()) + .isPositive(); + var actualService = operator.get(Service.class, service.getMetadata().getName()); + assertThat(actualService.getMetadata().getFinalizers()).isNotEmpty(); + }); + + operator.delete(service); + + await() + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(BuiltInResourceCleanerReconciler.class) + .getCleanCount()) + .isPositive(); + }); + } + + Service testService() { + Service service = + ReconcilerUtils.loadYaml( + Service.class, + StandaloneDependentResourceIT.class, + "/io/javaoperatorsdk/operator/service-template.yaml"); + service.getMetadata().setLabels(Map.of("builtintest", "true")); + return service; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerReconciler.java new file mode 100644 index 0000000000..5b42e795c3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/builtinresourcecleaner/BuiltInResourceCleanerReconciler.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.baseapi.builtinresourcecleaner; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.Service; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.*; + +@ControllerConfiguration(informer = @Informer(labelSelector = "builtintest=true")) +public class BuiltInResourceCleanerReconciler implements Reconciler, Cleaner { + + private final AtomicInteger reconciled = new AtomicInteger(0); + private final AtomicInteger cleaned = new AtomicInteger(0); + + @Override + public UpdateControl reconcile(Service resource, Context context) { + reconciled.addAndGet(1); + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup(Service resource, Context context) { + cleaned.addAndGet(1); + return DeleteControl.defaultDelete(); + } + + public int getReconcileCount() { + return reconciled.get(); + } + + public int getCleanCount() { + return cleaned.get(); + } +} 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 new file mode 100644 index 0000000000..67f65c64ca --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceIT.java @@ -0,0 +1,152 @@ +package io.javaoperatorsdk.operator.baseapi.changenamespace; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.Namespace; +import io.fabric8.kubernetes.api.model.NamespaceBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.RegisteredController; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class ChangeNamespaceIT { + + public static final String TEST_RESOURCE_NAME_1 = "test1"; + public static final String TEST_RESOURCE_NAME_2 = "test2"; + public static final String TEST_RESOURCE_NAME_3 = "test3"; + public static final String ADDITIONAL_TEST_NAMESPACE = "additional-test-namespace"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new ChangeNamespaceTestReconciler()) + .build(); + + @BeforeEach + void setup() { + client().namespaces().resource(additionalTestNamespace()).create(); + } + + @AfterEach + void cleanup() { + client().namespaces().resource(additionalTestNamespace()).delete(); + } + + @SuppressWarnings("rawtypes") + @Test + void addNewAndRemoveOldNamespaceTest() { + var reconciler = operator.getReconcilerOfType(ChangeNamespaceTestReconciler.class); + var defaultNamespaceResource = operator.create(customResource(TEST_RESOURCE_NAME_1)); + + assertReconciled(reconciler, defaultNamespaceResource); + var resourceInAdditionalTestNamespace = createResourceInAdditionalNamespace(); + + assertNotReconciled(reconciler, resourceInAdditionalTestNamespace); + // adding additional namespace + RegisteredController registeredController = + operator.getRegisteredControllerForReconcile(ChangeNamespaceTestReconciler.class); + registeredController.changeNamespaces( + Set.of(operator.getNamespace(), ADDITIONAL_TEST_NAMESPACE)); + + assertReconciled(reconciler, resourceInAdditionalTestNamespace); + + // removing a namespace + registeredController.changeNamespaces(Set.of(ADDITIONAL_TEST_NAMESPACE)); + + var newResourceInDefaultNamespace = operator.create(customResource(TEST_RESOURCE_NAME_3)); + assertNotReconciled(reconciler, newResourceInDefaultNamespace); + + ConfigMap firstMap = operator.get(ConfigMap.class, TEST_RESOURCE_NAME_1); + firstMap.setData(Map.of("data", "newdata")); + operator.replace(firstMap); + assertReconciled(reconciler, defaultNamespaceResource); + } + + @Test + void changeToWatchAllNamespaces() { + var reconciler = operator.getReconcilerOfType(ChangeNamespaceTestReconciler.class); + var resourceInAdditionalTestNamespace = createResourceInAdditionalNamespace(); + + assertNotReconciled(reconciler, resourceInAdditionalTestNamespace); + + var registeredController = + operator.getRegisteredControllerForReconcile(ChangeNamespaceTestReconciler.class); + + registeredController.changeNamespaces(Set.of(Constants.WATCH_ALL_NAMESPACES)); + + assertReconciled(reconciler, resourceInAdditionalTestNamespace); + + registeredController.changeNamespaces(Set.of(operator.getNamespace())); + + var defaultNamespaceResource = operator.create(customResource(TEST_RESOURCE_NAME_1)); + var resource2InAdditionalResource = createResourceInAdditionalNamespace(TEST_RESOURCE_NAME_3); + assertReconciled(reconciler, defaultNamespaceResource); + assertNotReconciled(reconciler, resource2InAdditionalResource); + } + + private static void assertReconciled( + ChangeNamespaceTestReconciler reconciler, + ChangeNamespaceTestCustomResource resourceInAdditionalTestNamespace) { + await() + .untilAsserted( + () -> + assertThat( + reconciler.numberOfResourceReconciliations( + resourceInAdditionalTestNamespace)) + .isEqualTo(2)); + } + + private static void assertNotReconciled( + ChangeNamespaceTestReconciler reconciler, + ChangeNamespaceTestCustomResource resourceInAdditionalTestNamespace) { + await() + .pollDelay(Duration.ofMillis(200)) + .untilAsserted( + () -> + assertThat( + reconciler.numberOfResourceReconciliations( + resourceInAdditionalTestNamespace)) + .isZero()); + } + + private ChangeNamespaceTestCustomResource createResourceInAdditionalNamespace() { + return createResourceInAdditionalNamespace(TEST_RESOURCE_NAME_2); + } + + private ChangeNamespaceTestCustomResource createResourceInAdditionalNamespace(String name) { + var res = customResource(name); + return client() + .resources(ChangeNamespaceTestCustomResource.class) + .inNamespace(ADDITIONAL_TEST_NAMESPACE) + .resource(res) + .create(); + } + + private KubernetesClient client() { + return operator.getKubernetesClient(); + } + + private Namespace additionalTestNamespace() { + return new NamespaceBuilder() + .withMetadata(new ObjectMetaBuilder().withName(ADDITIONAL_TEST_NAMESPACE).build()) + .build(); + } + + private ChangeNamespaceTestCustomResource customResource(String name) { + ChangeNamespaceTestCustomResource customResource = new ChangeNamespaceTestCustomResource(); + customResource.setMetadata(new ObjectMetaBuilder().withName(name).build()); + return customResource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestCustomResource.java new file mode 100644 index 0000000000..853d10e433 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestCustomResource.java @@ -0,0 +1,11 @@ +package io.javaoperatorsdk.operator.baseapi.changenamespace; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class ChangeNamespaceTestCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestCustomResourceStatus.java new file mode 100644 index 0000000000..009d1340e9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestCustomResourceStatus.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.baseapi.changenamespace; + +public class ChangeNamespaceTestCustomResourceStatus { + + private int numberOfStatusUpdates = 0; + + public int getNumberOfStatusUpdates() { + return numberOfStatusUpdates; + } + + public ChangeNamespaceTestCustomResourceStatus setNumberOfStatusUpdates( + int numberOfStatusUpdates) { + this.numberOfStatusUpdates = numberOfStatusUpdates; + return this; + } +} 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 new file mode 100644 index 0000000000..fdbac6fe00 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java @@ -0,0 +1,89 @@ +package io.javaoperatorsdk.operator.baseapi.changenamespace; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.*; +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 ChangeNamespaceTestReconciler + implements Reconciler { + + private final ConcurrentHashMap numberOfResourceReconciliations = + new ConcurrentHashMap<>(); + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + InformerEventSource configMapES = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, ChangeNamespaceTestCustomResource.class) + .build(), + context); + + return List.of(configMapES); + } + + @Override + 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(); + } + + if (primary.getStatus() == null) { + primary.setStatus(new ChangeNamespaceTestCustomResourceStatus()); + } + increaseNumberOfResourceExecutions(primary); + + var statusPatchResource = new ChangeNamespaceTestCustomResource(); + statusPatchResource.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + statusPatchResource.setStatus(new ChangeNamespaceTestCustomResourceStatus()); + var statusUpdates = primary.getStatus().getNumberOfStatusUpdates(); + statusPatchResource.getStatus().setNumberOfStatusUpdates(statusUpdates + 1); + return UpdateControl.patchStatus(statusPatchResource); + } + + private void increaseNumberOfResourceExecutions(ChangeNamespaceTestCustomResource primary) { + var resourceID = ResourceID.fromResource(primary); + var num = numberOfResourceReconciliations.getOrDefault(resourceID, 0); + numberOfResourceReconciliations.put(resourceID, num + 1); + } + + public int numberOfResourceReconciliations(ChangeNamespaceTestCustomResource primary) { + return numberOfResourceReconciliations.getOrDefault(ResourceID.fromResource(primary), 0); + } + + private ConfigMap configMap(ChangeNamespaceTestCustomResource primary) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of("data", primary.getMetadata().getName())); + configMap.addOwnerReference(primary); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanerforreconciler/CleanerForReconcilerCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanerforreconciler/CleanerForReconcilerCustomResource.java new file mode 100644 index 0000000000..d4f8aa6b0c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanerforreconciler/CleanerForReconcilerCustomResource.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.cleanerforreconciler; + +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("CleanerForReconcilerCustomResource") +@ShortNames("cfr") +public class CleanerForReconcilerCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanerforreconciler/CleanerForReconcilerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanerforreconciler/CleanerForReconcilerIT.java new file mode 100644 index 0000000000..6ccb34f0e3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanerforreconciler/CleanerForReconcilerIT.java @@ -0,0 +1,88 @@ +package io.javaoperatorsdk.operator.baseapi.cleanerforreconciler; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class CleanerForReconcilerIT { + + public static final String TEST_RESOURCE_NAME = "cleaner-for-reconciler-test1"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new CleanerForReconcilerTestReconciler()) + .build(); + + @Test + void addsFinalizerAndCallsCleanupIfCleanerImplemented() { + CleanerForReconcilerTestReconciler reconciler = + operator.getReconcilerOfType(CleanerForReconcilerTestReconciler.class); + reconciler.setReScheduleCleanup(false); + + var testResource = createTestResource(); + operator.create(testResource); + + await() + .until( + () -> + !operator + .get(CleanerForReconcilerCustomResource.class, TEST_RESOURCE_NAME) + .getMetadata() + .getFinalizers() + .isEmpty()); + + operator.delete(testResource); + + await() + .until( + () -> + operator.get(CleanerForReconcilerCustomResource.class, TEST_RESOURCE_NAME) == null); + + assertThat(reconciler.getNumberOfExecutions()).isEqualTo(1); + assertThat(reconciler.getNumberOfCleanupExecutions()).isEqualTo(1); + } + + @Test + void reSchedulesCleanupIfInstructed() { + CleanerForReconcilerTestReconciler reconciler = + operator.getReconcilerOfType(CleanerForReconcilerTestReconciler.class); + reconciler.setReScheduleCleanup(true); + + var testResource = createTestResource(); + operator.create(testResource); + + await() + .until( + () -> + !operator + .get(CleanerForReconcilerCustomResource.class, TEST_RESOURCE_NAME) + .getMetadata() + .getFinalizers() + .isEmpty()); + + operator.delete(testResource); + + await() + .untilAsserted( + () -> assertThat(reconciler.getNumberOfCleanupExecutions()).isGreaterThan(5)); + + reconciler.setReScheduleCleanup(false); + await() + .until( + () -> + operator.get(CleanerForReconcilerCustomResource.class, TEST_RESOURCE_NAME) == null); + } + + private CleanerForReconcilerCustomResource createTestResource() { + CleanerForReconcilerCustomResource cr = new CleanerForReconcilerCustomResource(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName(TEST_RESOURCE_NAME); + return cr; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanerforreconciler/CleanerForReconcilerTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanerforreconciler/CleanerForReconcilerTestReconciler.java new file mode 100644 index 0000000000..4c19e41952 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanerforreconciler/CleanerForReconcilerTestReconciler.java @@ -0,0 +1,53 @@ +package io.javaoperatorsdk.operator.baseapi.cleanerforreconciler; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration +public class CleanerForReconcilerTestReconciler + implements Reconciler, + Cleaner, + TestExecutionInfoProvider { + + public static final int RESCHEDULE_DELAY = 150; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private final AtomicInteger numberOfCleanupExecutions = new AtomicInteger(0); + + private volatile boolean reScheduleCleanup = false; + + @Override + public UpdateControl reconcile( + CleanerForReconcilerCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + public int getNumberOfCleanupExecutions() { + return numberOfCleanupExecutions.get(); + } + + @Override + public DeleteControl cleanup( + CleanerForReconcilerCustomResource resource, + Context context) { + if (reScheduleCleanup) { + numberOfCleanupExecutions.addAndGet(1); + return DeleteControl.noFinalizerRemoval().rescheduleAfter(RESCHEDULE_DELAY); + } else { + numberOfCleanupExecutions.addAndGet(1); + return DeleteControl.defaultDelete(); + } + } + + public CleanerForReconcilerTestReconciler setReScheduleCleanup(boolean reScheduleCleanup) { + this.reScheduleCleanup = reScheduleCleanup; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictCustomResource.java new file mode 100644 index 0000000000..58a118761d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictCustomResource.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.cleanupconflict; + +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("CleanupConflictCustomResource") +@ShortNames("ccc") +public class CleanupConflictCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictCustomResourceStatus.java new file mode 100644 index 0000000000..19794e2173 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictCustomResourceStatus.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.cleanupconflict; + +public class CleanupConflictCustomResourceStatus { + + private Integer value = 0; + + public Integer getValue() { + return value; + } + + public CleanupConflictCustomResourceStatus setValue(Integer value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictIT.java new file mode 100644 index 0000000000..19ef6df9a0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictIT.java @@ -0,0 +1,63 @@ +package io.javaoperatorsdk.operator.baseapi.cleanupconflict; + +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.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.cleanupconflict.CleanupConflictReconciler.WAIT_TIME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class CleanupConflictIT { + + private static final String ADDITIONAL_FINALIZER = "javaoperatorsdk.io/additionalfinalizer"; + public static final String TEST_RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(new CleanupConflictReconciler()).build(); + + @Test + void cleanupRemovesFinalizerWithoutConflict() throws InterruptedException { + var testResource = createTestResource(); + testResource.addFinalizer(ADDITIONAL_FINALIZER); + testResource = operator.create(testResource); + + await() + .untilAsserted( + () -> + assertThat( + operator + .getReconcilerOfType(CleanupConflictReconciler.class) + .getNumberReconcileExecutions()) + .isEqualTo(1)); + + operator.delete(testResource); + Thread.sleep(WAIT_TIME / 2); + testResource = operator.get(CleanupConflictCustomResource.class, TEST_RESOURCE_NAME); + testResource.getMetadata().getFinalizers().remove(ADDITIONAL_FINALIZER); + testResource.getMetadata().setResourceVersion(null); + operator.replace(testResource); + + await() + .pollDelay(Duration.ofMillis(WAIT_TIME * 2)) + .untilAsserted( + () -> + assertThat( + operator + .getReconcilerOfType(CleanupConflictReconciler.class) + .getNumberOfCleanupExecutions()) + .isEqualTo(1)); + } + + private CleanupConflictCustomResource createTestResource() { + CleanupConflictCustomResource cr = new CleanupConflictCustomResource(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName(TEST_RESOURCE_NAME); + return cr; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictReconciler.java new file mode 100644 index 0000000000..7f2bb64896 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cleanupconflict/CleanupConflictReconciler.java @@ -0,0 +1,41 @@ +package io.javaoperatorsdk.operator.baseapi.cleanupconflict; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; + +@ControllerConfiguration +public class CleanupConflictReconciler + implements Reconciler, Cleaner { + + public static final long WAIT_TIME = 500L; + private final AtomicInteger numberOfCleanupExecutions = new AtomicInteger(0); + private final AtomicInteger numberReconcileExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + CleanupConflictCustomResource resource, Context context) { + numberReconcileExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup( + CleanupConflictCustomResource resource, Context context) { + numberOfCleanupExecutions.addAndGet(1); + try { + Thread.sleep(WAIT_TIME); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return DeleteControl.defaultDelete(); + } + + public int getNumberOfCleanupExecutions() { + return numberOfCleanupExecutions.intValue(); + } + + public int getNumberReconcileExecutions() { + return numberReconcileExecutions.intValue(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResource.java new file mode 100644 index 0000000000..11250ed2b3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResource.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.baseapi.clusterscopedresource; + +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("csc") +public class ClusterScopedCustomResource + extends CustomResource {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceReconciler.java new file mode 100644 index 0000000000..e8b12d6eba --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceReconciler.java @@ -0,0 +1,77 @@ +package io.javaoperatorsdk.operator.baseapi.clusterscopedresource; + +import java.util.List; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers; + +@ControllerConfiguration +public class ClusterScopedCustomResourceReconciler + implements Reconciler { + + public static final String DATA_KEY = "data-key"; + + public static final String TEST_LABEL_VALUE = "clusterscopecrtest"; + public static final String TEST_LABEL_KEY = "test"; + + @Override + public UpdateControl reconcile( + ClusterScopedCustomResource resource, Context context) { + + var optionalConfigMap = context.getSecondaryResource(ConfigMap.class); + + final var client = context.getClient(); + optionalConfigMap.ifPresentOrElse( + cm -> { + if (!resource.getSpec().getData().equals(cm.getData().get(DATA_KEY))) { + client.configMaps().resource(desired(resource)).replace(); + } + }, + () -> client.configMaps().resource(desired(resource)).create()); + + resource.setStatus(new ClusterScopedCustomResourceStatus()); + resource.getStatus().setCreated(true); + return UpdateControl.patchStatus(resource); + } + + private ConfigMap desired(ClusterScopedCustomResource resource) { + var cm = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getSpec().getTargetNamespace()) + .withLabels(Map.of(TEST_LABEL_KEY, TEST_LABEL_VALUE)) + .build()) + .withData(Map.of(DATA_KEY, resource.getSpec().getData())) + .build(); + cm.addOwnerReference(resource); + return cm; + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + var ies = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, ClusterScopedCustomResource.class) + .withSecondaryToPrimaryMapper( + Mappers.fromOwnerReferences(context.getPrimaryResourceClass(), true)) + .withLabelSelector(TEST_LABEL_KEY + "=" + TEST_LABEL_VALUE) + .build(), + context); + return List.of(ies); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceSpec.java new file mode 100644 index 0000000000..8facc07e18 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceSpec.java @@ -0,0 +1,25 @@ +package io.javaoperatorsdk.operator.baseapi.clusterscopedresource; + +public class ClusterScopedCustomResourceSpec { + + private String data; + private String targetNamespace; + + public String getData() { + return data; + } + + public ClusterScopedCustomResourceSpec setData(String data) { + this.data = data; + return this; + } + + public String getTargetNamespace() { + return targetNamespace; + } + + public ClusterScopedCustomResourceSpec setTargetNamespace(String targetNamespace) { + this.targetNamespace = targetNamespace; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceStatus.java new file mode 100644 index 0000000000..7ed5c6c1b4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedCustomResourceStatus.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.clusterscopedresource; + +public class ClusterScopedCustomResourceStatus { + + private Boolean created; + + public Boolean getCreated() { + return created; + } + + public ClusterScopedCustomResourceStatus setCreated(Boolean created) { + this.created = created; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedResourceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedResourceIT.java new file mode 100644 index 0000000000..4c92fbada7 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/clusterscopedresource/ClusterScopedResourceIT.java @@ -0,0 +1,70 @@ +package io.javaoperatorsdk.operator.baseapi.clusterscopedresource; + +import java.time.Duration; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.IntegrationTestConstants.GARBAGE_COLLECTION_TIMEOUT_SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class ClusterScopedResourceIT { + + public static final String TEST_NAME = "test1"; + public static final String INITIAL_DATA = "initialData"; + public static final String UPDATED_DATA = "updatedData"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new ClusterScopedCustomResourceReconciler()) + .build(); + + @Test + void crudOperationOnClusterScopedCustomResource() { + var resource = operator.create(testResource()); + + await() + .untilAsserted( + () -> { + var res = operator.get(ClusterScopedCustomResource.class, TEST_NAME); + assertThat(res.getStatus()).isNotNull(); + assertThat(res.getStatus().getCreated()).isTrue(); + var cm = operator.get(ConfigMap.class, TEST_NAME); + assertThat(cm).isNotNull(); + assertThat(cm.getData().get(ClusterScopedCustomResourceReconciler.DATA_KEY)) + .isEqualTo(INITIAL_DATA); + }); + + resource.getSpec().setData(UPDATED_DATA); + operator.replace(resource); + await() + .untilAsserted( + () -> { + var cm = operator.get(ConfigMap.class, TEST_NAME); + assertThat(cm).isNotNull(); + assertThat(cm.getData().get(ClusterScopedCustomResourceReconciler.DATA_KEY)) + .isEqualTo(UPDATED_DATA); + }); + + operator.delete(resource); + await() + .atMost(Duration.ofSeconds(GARBAGE_COLLECTION_TIMEOUT_SECONDS)) + .untilAsserted(() -> assertThat(operator.get(ConfigMap.class, TEST_NAME)).isNull()); + } + + ClusterScopedCustomResource testResource() { + var res = new ClusterScopedCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_NAME).build()); + res.setSpec(new ClusterScopedCustomResourceSpec()); + res.getSpec().setTargetNamespace(operator.getNamespace()); + res.getSpec().setData(INITIAL_DATA); + + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalCustomResource.java new file mode 100644 index 0000000000..3f9b61d1a8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.concurrentfinalizerremoval; + +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("cfr") +public class ConcurrentFinalizerRemovalCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalIT.java new file mode 100644 index 0000000000..da166ce2c1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalIT.java @@ -0,0 +1,68 @@ +package io.javaoperatorsdk.operator.baseapi.concurrentfinalizerremoval; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class ConcurrentFinalizerRemovalIT { + + private static final Logger log = LoggerFactory.getLogger(ConcurrentFinalizerRemovalIT.class); + public static final String TEST_RESOURCE_NAME = "test"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + // should work without a retry, thus not retry the whole reconciliation but to retry + // finalizer removal only. + .withReconciler( + new ConcurrentFinalizerRemovalReconciler1(), + o -> + o.withRetry(GenericRetry.noRetry()).withFinalizer("reconciler1.sample/finalizer")) + .withReconciler( + new ConcurrentFinalizerRemovalReconciler2(), + o -> + o.withRetry(GenericRetry.noRetry()).withFinalizer("reconciler2.sample/finalizer")) + .build(); + + @Test + void concurrentFinalizerRemoval() { + for (int i = 0; i < 10; i++) { + var resource = extension.create(createResource()); + await() + .untilAsserted( + () -> { + var res = + extension.get( + ConcurrentFinalizerRemovalCustomResource.class, TEST_RESOURCE_NAME); + assertThat(res.getMetadata().getFinalizers()).hasSize(2); + }); + resource.getMetadata().setResourceVersion(null); + extension.delete(resource); + + await() + .untilAsserted( + () -> { + var res = + extension.get( + ConcurrentFinalizerRemovalCustomResource.class, TEST_RESOURCE_NAME); + assertThat(res).isNull(); + }); + } + } + + public ConcurrentFinalizerRemovalCustomResource createResource() { + ConcurrentFinalizerRemovalCustomResource res = new ConcurrentFinalizerRemovalCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + res.setSpec(new ConcurrentFinalizerRemovalSpec()); + res.getSpec().setNumber(0); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalReconciler1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalReconciler1.java new file mode 100644 index 0000000000..b789255cb5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalReconciler1.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.baseapi.concurrentfinalizerremoval; + +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +@ControllerConfiguration +public class ConcurrentFinalizerRemovalReconciler1 + implements Reconciler, + Cleaner { + + @Override + public UpdateControl reconcile( + ConcurrentFinalizerRemovalCustomResource resource, + Context context) { + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup( + ConcurrentFinalizerRemovalCustomResource resource, + Context context) + throws Exception { + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalReconciler2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalReconciler2.java new file mode 100644 index 0000000000..0b8993a8f5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalReconciler2.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.baseapi.concurrentfinalizerremoval; + +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +@ControllerConfiguration +public class ConcurrentFinalizerRemovalReconciler2 + implements Reconciler, + Cleaner { + + @Override + public UpdateControl reconcile( + ConcurrentFinalizerRemovalCustomResource resource, + Context context) { + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup( + ConcurrentFinalizerRemovalCustomResource resource, + Context context) + throws Exception { + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalSpec.java new file mode 100644 index 0000000000..d740721f30 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/concurrentfinalizerremoval/ConcurrentFinalizerRemovalSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.concurrentfinalizerremoval; + +public class ConcurrentFinalizerRemovalSpec { + + private int number; + + public int getNumber() { + return number; + } + + public ConcurrentFinalizerRemovalSpec setNumber(int number) { + this.number = number; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestCustomResource.java new file mode 100644 index 0000000000..9b56bbc650 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.createupdateeventfilter; + +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("cue") +public class CreateUpdateEventFilterTestCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestCustomResourceSpec.java new file mode 100644 index 0000000000..fb38fa9f39 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestCustomResourceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.createupdateeventfilter; + +public class CreateUpdateEventFilterTestCustomResourceSpec { + + private String value; + + public String getValue() { + return value; + } + + public CreateUpdateEventFilterTestCustomResourceSpec setValue(String value) { + this.value = value; + return this; + } +} 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 new file mode 100644 index 0000000000..664e75a950 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateEventFilterTestReconciler.java @@ -0,0 +1,114 @@ +package io.javaoperatorsdk.operator.baseapi.createupdateeventfilter; + +import java.util.HashMap; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration +public class CreateUpdateEventFilterTestReconciler + implements Reconciler { + + public static final String CONFIG_MAP_TEST_DATA_KEY = "key"; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private final DirectConfigMapDependentResource configMapDR = + new DirectConfigMapDependentResource(ConfigMap.class); + + @Override + public UpdateControl reconcile( + CreateUpdateEventFilterTestCustomResource resource, + Context context) { + numberOfExecutions.incrementAndGet(); + + ConfigMap configMap = + context + .getClient() + .configMaps() + .inNamespace(resource.getMetadata().getNamespace()) + .withName(resource.getMetadata().getName()) + .get(); + if (configMap == null) { + configMapDR.desired = createConfigMap(resource); + configMapDR.reconcile(resource, context); + } else { + if (!Objects.equals( + configMap.getData().get(CONFIG_MAP_TEST_DATA_KEY), resource.getSpec().getValue())) { + configMap.getData().put(CONFIG_MAP_TEST_DATA_KEY, resource.getSpec().getValue()); + configMapDR.desired = configMap; + configMapDR.reconcile(resource, context); + } + } + return UpdateControl.noUpdate(); + } + + private ConfigMap createConfigMap(CreateUpdateEventFilterTestCustomResource resource) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new ObjectMeta()); + configMap.getMetadata().setName(resource.getMetadata().getName()); + configMap.getMetadata().setLabels(new HashMap<>()); + configMap.getMetadata().getLabels().put("integrationtest", this.getClass().getSimpleName()); + configMap.getMetadata().setNamespace(resource.getMetadata().getNamespace()); + configMap.setData(new HashMap<>()); + configMap.getData().put(CONFIG_MAP_TEST_DATA_KEY, resource.getSpec().getValue()); + configMap.addOwnerReference(resource); + + return configMap; + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + InformerEventSourceConfiguration informerConfiguration = + InformerEventSourceConfiguration.from( + ConfigMap.class, CreateUpdateEventFilterTestCustomResource.class) + .withLabelSelector("integrationtest = " + this.getClass().getSimpleName()) + .build(); + + final var informerEventSource = new InformerEventSource<>(informerConfiguration, context); + this.configMapDR.setEventSource(informerEventSource); + + return List.of(informerEventSource); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + private static final class DirectConfigMapDependentResource + extends CRUDKubernetesDependentResource< + ConfigMap, CreateUpdateEventFilterTestCustomResource> { + + private ConfigMap desired; + + private DirectConfigMapDependentResource(Class resourceType) { + super(resourceType); + } + + @Override + protected ConfigMap desired( + CreateUpdateEventFilterTestCustomResource primary, + Context context) { + return desired; + } + + @Override + public void setEventSource( + io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource< + ConfigMap, CreateUpdateEventFilterTestCustomResource> + eventSource) { + super.setEventSource(eventSource); + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateInformerEventSourceEventFilterIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateInformerEventSourceEventFilterIT.java new file mode 100644 index 0000000000..2d9a7db573 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/CreateUpdateInformerEventSourceEventFilterIT.java @@ -0,0 +1,75 @@ +package io.javaoperatorsdk.operator.baseapi.createupdateeventfilter; + +import java.time.Duration; + +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.ObjectMeta; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.createupdateeventfilter.CreateUpdateEventFilterTestReconciler.CONFIG_MAP_TEST_DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class CreateUpdateInformerEventSourceEventFilterIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new CreateUpdateEventFilterTestReconciler()) + .build(); + + @Test + void updateEventNotReceivedAfterCreateOrUpdate() { + CreateUpdateEventFilterTestCustomResource resource = + CreateUpdateInformerEventSourceEventFilterIT.prepareTestResource(); + var createdResource = operator.create(resource); + + assertData(operator, createdResource, 1, 1); + + CreateUpdateEventFilterTestCustomResource actualCreatedResource = + operator.get( + CreateUpdateEventFilterTestCustomResource.class, resource.getMetadata().getName()); + actualCreatedResource.getSpec().setValue("2"); + operator.replace(actualCreatedResource); + + assertData(operator, actualCreatedResource, 2, 2); + } + + static void assertData( + LocallyRunOperatorExtension operator, + CreateUpdateEventFilterTestCustomResource resource, + int minExecutions, + int maxExecutions) { + await() + .atMost(Duration.ofSeconds(1)) + .until( + () -> { + var cm = operator.get(ConfigMap.class, resource.getMetadata().getName()); + if (cm == null) { + return false; + } + return cm.getData() + .get(CONFIG_MAP_TEST_DATA_KEY) + .equals(resource.getSpec().getValue()); + }); + + int numberOfExecutions = + ((CreateUpdateEventFilterTestReconciler) operator.getFirstReconciler()) + .getNumberOfExecutions(); + assertThat(numberOfExecutions).isGreaterThanOrEqualTo(minExecutions); + assertThat(numberOfExecutions).isLessThanOrEqualTo(maxExecutions); + } + + static CreateUpdateEventFilterTestCustomResource prepareTestResource() { + CreateUpdateEventFilterTestCustomResource resource = + new CreateUpdateEventFilterTestCustomResource(); + resource.setMetadata(new ObjectMeta()); + resource.getMetadata().setName("test1"); + resource.setSpec(new CreateUpdateEventFilterTestCustomResourceSpec()); + resource.getSpec().setValue("1"); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/PreviousAnnotationDisabledIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/PreviousAnnotationDisabledIT.java new file mode 100644 index 0000000000..b5554493ee --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/createupdateeventfilter/PreviousAnnotationDisabledIT.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.baseapi.createupdateeventfilter; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +class PreviousAnnotationDisabledIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new CreateUpdateEventFilterTestReconciler()) + .withConfigurationService( + overrider -> overrider.withPreviousAnnotationForDependentResources(false)) + .build(); + + @Test + void updateEventReceivedAfterCreateOrUpdate() { + CreateUpdateEventFilterTestCustomResource resource = + CreateUpdateInformerEventSourceEventFilterIT.prepareTestResource(); + var createdResource = operator.create(resource); + + CreateUpdateInformerEventSourceEventFilterIT.assertData(operator, createdResource, 1, 2); + + CreateUpdateEventFilterTestCustomResource actualCreatedResource = + operator.get( + CreateUpdateEventFilterTestCustomResource.class, resource.getMetadata().getName()); + actualCreatedResource.getSpec().setValue("2"); + operator.replace(actualCreatedResource); + + CreateUpdateInformerEventSourceEventFilterIT.assertData(operator, actualCreatedResource, 2, 4); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deployment/DeploymentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deployment/DeploymentReconciler.java new file mode 100644 index 0000000000..27ea60852b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deployment/DeploymentReconciler.java @@ -0,0 +1,56 @@ +package io.javaoperatorsdk.operator.baseapi.deployment; + +import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentCondition; +import io.fabric8.kubernetes.api.model.apps.DeploymentStatus; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +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; + +@ControllerConfiguration( + informer = @Informer(labelSelector = "test=KubernetesResourceStatusUpdateIT")) +public class DeploymentReconciler implements Reconciler, TestExecutionInfoProvider { + + public static final String STATUS_MESSAGE = "Reconciled by DeploymentReconciler"; + + private static final Logger log = LoggerFactory.getLogger(DeploymentReconciler.class); + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile(Deployment resource, Context context) { + + log.info("Reconcile deployment: {}", resource); + numberOfExecutions.incrementAndGet(); + if (resource.getStatus() == null) { + resource.setStatus(new DeploymentStatus()); + } + if (resource.getStatus().getConditions() == null) { + resource.getStatus().setConditions(new ArrayList<>()); + } + var conditions = resource.getStatus().getConditions(); + var condition = + conditions.stream().filter(c -> c.getMessage().equals(STATUS_MESSAGE)).findFirst(); + if (condition.isEmpty()) { + conditions.add( + new DeploymentCondition( + null, null, STATUS_MESSAGE, null, "unknown", "DeploymentReconciler")); + return UpdateControl.patchStatus(resource); + } else { + return UpdateControl.noUpdate(); + } + } + + @Override + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deployment/KubernetesResourceStatusUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deployment/KubernetesResourceStatusUpdateIT.java new file mode 100644 index 0000000000..5fe9bfa939 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deployment/KubernetesResourceStatusUpdateIT.java @@ -0,0 +1,82 @@ +package io.javaoperatorsdk.operator.baseapi.deployment; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.Container; +import io.fabric8.kubernetes.api.model.ContainerPort; +import io.fabric8.kubernetes.api.model.LabelSelector; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.PodSpec; +import io.fabric8.kubernetes.api.model.PodTemplateSpec; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentSpec; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.deployment.DeploymentReconciler.STATUS_MESSAGE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class KubernetesResourceStatusUpdateIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(new DeploymentReconciler()).build(); + + @Test + void testReconciliationOfNonCustomResourceAndStatusUpdate() { + var deployment = operator.create(testDeployment()); + await() + .atMost(120, TimeUnit.SECONDS) + .untilAsserted( + () -> { + var d = operator.get(Deployment.class, deployment.getMetadata().getName()); + assertThat(d.getStatus()).isNotNull(); + assertThat(d.getStatus().getConditions()).isNotNull(); + // wait until the pod is ready, if not this is causing some test stability issues with + // namespace cleanup in k8s version 1.22 + assertThat(d.getStatus().getReadyReplicas()).isGreaterThanOrEqualTo(1); + assertThat( + d.getStatus().getConditions().stream() + .filter(c -> c.getMessage().equals(STATUS_MESSAGE)) + .count()) + .isEqualTo(1); + }); + } + + private Deployment testDeployment() { + Deployment resource = new Deployment(); + Map labels = new HashMap<>(); + labels.put("test", "KubernetesResourceStatusUpdateIT"); + resource.setMetadata( + new ObjectMetaBuilder().withName("test-deployment").withLabels(labels).build()); + DeploymentSpec spec = new DeploymentSpec(); + resource.setSpec(spec); + spec.setReplicas(1); + var labelSelector = new HashMap(); + labelSelector.put("app", "nginx"); + spec.setSelector(new LabelSelector(null, labelSelector)); + PodTemplateSpec podTemplate = new PodTemplateSpec(); + spec.setTemplate(podTemplate); + + podTemplate.setMetadata(new ObjectMeta()); + podTemplate.getMetadata().setLabels(labelSelector); + podTemplate.setSpec(new PodSpec()); + + Container container = new Container(); + container.setName("nginx"); + container.setImage("nginx:1.21.4"); + ContainerPort port = new ContainerPort(); + port.setContainerPort(80); + container.setPorts(List.of(port)); + + podTemplate.getSpec().setContainers(List.of(container)); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationCustomResource.java new file mode 100644 index 0000000000..df1273c4d9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.dynamicgenericeventsourceregistration; + +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("dger") +public class DynamicGenericEventSourceRegistrationCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationIT.java new file mode 100644 index 0000000000..5c05850b4a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationIT.java @@ -0,0 +1,63 @@ +package io.javaoperatorsdk.operator.baseapi.dynamicgenericeventsourceregistration; + +import java.time.Duration; + +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.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class DynamicGenericEventSourceRegistrationIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(DynamicGenericEventSourceRegistrationReconciler.class) + .build(); + + @Test + void registersEventSourcesDynamically() { + var reconciler = + extension.getReconcilerOfType(DynamicGenericEventSourceRegistrationReconciler.class); + extension.create(testResource()); + + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); + var secret = extension.get(Secret.class, TEST_RESOURCE_NAME); + assertThat(cm).isNotNull(); + assertThat(secret).isNotNull(); + }); + var executions = reconciler.getNumberOfExecutions(); + assertThat(reconciler.getNumberOfEventSources()).isEqualTo(2); + assertThat(executions).isLessThanOrEqualTo(3); + + var cm = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); + cm.getData().put("key2", "val2"); + + extension.replace(cm); // triggers the reconciliation + + await() + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions() - executions).isEqualTo(2); + }); + assertThat(reconciler.getNumberOfEventSources()).isEqualTo(2); + } + + DynamicGenericEventSourceRegistrationCustomResource testResource() { + var res = new DynamicGenericEventSourceRegistrationCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationReconciler.java new file mode 100644 index 0000000000..1d018e55dc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/dynamicgenericeventsourceregistration/DynamicGenericEventSourceRegistrationReconciler.java @@ -0,0 +1,97 @@ +package io.javaoperatorsdk.operator.baseapi.dynamicgenericeventsourceregistration; + +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.*; +import io.fabric8.kubernetes.client.dsl.NonDeletingOperation; +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.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration +public class DynamicGenericEventSourceRegistrationReconciler + implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private final AtomicInteger numberOfEventSources = new AtomicInteger(); + + @Override + public UpdateControl reconcile( + DynamicGenericEventSourceRegistrationCustomResource primary, + Context context) { + + numberOfExecutions.addAndGet(1); + + context + .eventSourceRetriever() + .dynamicallyRegisterEventSource(genericInformerFor(ConfigMap.class, context)); + context + .eventSourceRetriever() + .dynamicallyRegisterEventSource(genericInformerFor(Secret.class, context)); + + context.getClient().resource(secret(primary)).createOr(NonDeletingOperation::update); + context.getClient().resource(configMap(primary)).createOr(NonDeletingOperation::update); + + numberOfEventSources.set( + context.eventSourceRetriever().getEventSourcesFor(GenericKubernetesResource.class).size()); + + return UpdateControl.noUpdate(); + } + + private Secret secret(DynamicGenericEventSourceRegistrationCustomResource primary) { + var secret = + new SecretBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of("key", Base64.getEncoder().encodeToString("val".getBytes()))) + .build(); + secret.addOwnerReference(primary); + return secret; + } + + private ConfigMap configMap(DynamicGenericEventSourceRegistrationCustomResource primary) { + var cm = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of("key", "val")) + .build(); + cm.addOwnerReference(primary); + return cm; + } + + private InformerEventSource< + GenericKubernetesResource, DynamicGenericEventSourceRegistrationCustomResource> + genericInformerFor( + Class clazz, + Context context) { + + return new InformerEventSource<>( + InformerEventSourceConfiguration.from( + GroupVersionKind.gvkFor(clazz), + DynamicGenericEventSourceRegistrationCustomResource.class) + .withName(clazz.getSimpleName()) + .build(), + context.eventSourceRetriever().eventSourceContextForDynamicRegistration()); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + public int getNumberOfEventSources() { + return numberOfEventSources.get(); + } +} 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 new file mode 100644 index 0000000000..b888143d2d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerIT.java @@ -0,0 +1,56 @@ +package io.javaoperatorsdk.operator.baseapi.errorstatushandler; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class ErrorStatusHandlerIT { + + public static final int MAX_RETRY_ATTEMPTS = 3; + ErrorStatusHandlerTestReconciler reconciler = new ErrorStatusHandlerTestReconciler(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler( + reconciler, new GenericRetry().setMaxAttempts(MAX_RETRY_ATTEMPTS).withLinearRetry()) + .build(); + + @Test + void testErrorMessageSetEventually() { + ErrorStatusHandlerTestCustomResource resource = operator.create(createCustomResource()); + + await() + .atMost(10, TimeUnit.SECONDS) + .pollInterval(250, TimeUnit.MICROSECONDS) + .untilAsserted( + () -> { + ErrorStatusHandlerTestCustomResource res = + operator.get( + ErrorStatusHandlerTestCustomResource.class, resource.getMetadata().getName()); + assertThat(res.getStatus()).isNotNull(); + for (int i = 0; i < MAX_RETRY_ATTEMPTS + 1; i++) { + assertThat(res.getStatus().getMessages()) + .contains(ErrorStatusHandlerTestReconciler.ERROR_STATUS_MESSAGE + i); + } + }); + } + + public ErrorStatusHandlerTestCustomResource createCustomResource() { + ErrorStatusHandlerTestCustomResource resource = new ErrorStatusHandlerTestCustomResource(); + resource.setMetadata( + new ObjectMetaBuilder() + .withName("error-status-test") + .withNamespace(operator.getNamespace()) + .build()); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResource.java new file mode 100644 index 0000000000..2e4664a5de --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResource.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.baseapi.errorstatushandler; + +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("ErrorStatusHandlerTestCustomResource") +@ShortNames("esh") +public class ErrorStatusHandlerTestCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResourceStatus.java new file mode 100644 index 0000000000..42fe4b4b34 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResourceStatus.java @@ -0,0 +1,21 @@ +package io.javaoperatorsdk.operator.baseapi.errorstatushandler; + +import java.util.ArrayList; +import java.util.List; + +public class ErrorStatusHandlerTestCustomResourceStatus { + + private List messages; + + public List getMessages() { + if (messages == null) { + messages = new ArrayList<>(); + } + return messages; + } + + public ErrorStatusHandlerTestCustomResourceStatus setMessages(List messages) { + this.messages = messages; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestReconciler.java new file mode 100644 index 0000000000..d917faab93 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestReconciler.java @@ -0,0 +1,61 @@ +package io.javaoperatorsdk.operator.baseapi.errorstatushandler; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration +public class ErrorStatusHandlerTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private static final Logger log = LoggerFactory.getLogger(ErrorStatusHandlerTestReconciler.class); + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + public static final String ERROR_STATUS_MESSAGE = "Error Retries Exceeded"; + + @Override + public UpdateControl reconcile( + ErrorStatusHandlerTestCustomResource resource, + Context context) { + var number = numberOfExecutions.addAndGet(1); + var retryAttempt = -1; + if (context.getRetryInfo().isPresent()) { + retryAttempt = context.getRetryInfo().get().getAttemptCount(); + } + log.info( + "Number of execution: {} retry attempt: {} , resource: {}", + number, + retryAttempt, + resource); + throw new IllegalStateException(); + } + + private void ensureStatusExists(ErrorStatusHandlerTestCustomResource resource) { + ErrorStatusHandlerTestCustomResourceStatus status = resource.getStatus(); + if (status == null) { + status = new ErrorStatusHandlerTestCustomResourceStatus(); + resource.setStatus(status); + } + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + ErrorStatusHandlerTestCustomResource resource, + Context context, + Exception e) { + log.info("Setting status."); + ensureStatusExists(resource); + resource + .getStatus() + .getMessages() + .add(ERROR_STATUS_MESSAGE + context.getRetryInfo().orElseThrow().getAttemptCount()); + return ErrorStatusUpdateControl.patchStatus(resource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceIT.java new file mode 100644 index 0000000000..eaa881709b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceIT.java @@ -0,0 +1,47 @@ +package io.javaoperatorsdk.operator.baseapi.event; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.TestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class EventSourceIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(EventSourceTestCustomReconciler.class) + .build(); + + @Test + void receivingPeriodicEvents() { + EventSourceTestCustomResource resource = createTestCustomResource("1"); + + operator.create(resource); + + await() + .atMost(5, TimeUnit.SECONDS) + .pollInterval(EventSourceTestCustomReconciler.TIMER_PERIOD / 2, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> assertThat(TestUtils.getNumberOfExecutions(operator)).isGreaterThanOrEqualTo(4)); + } + + public EventSourceTestCustomResource createTestCustomResource(String id) { + EventSourceTestCustomResource resource = new EventSourceTestCustomResource(); + resource.setMetadata( + new ObjectMetaBuilder() + .withName("eventsource-" + id) + .withNamespace(operator.getNamespace()) + .build()); + resource.setSpec(new EventSourceTestCustomResourceSpec()); + resource.getSpec().setValue(id); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomReconciler.java new file mode 100644 index 0000000000..2993a29103 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomReconciler.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.baseapi.event; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration +public class EventSourceTestCustomReconciler + implements Reconciler, TestExecutionInfoProvider { + + public static final int TIMER_PERIOD = 500; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + EventSourceTestCustomResource resource, Context context) { + + numberOfExecutions.addAndGet(1); + ensureStatusExists(resource); + resource.getStatus().setState(EventSourceTestCustomResourceStatus.State.SUCCESS); + + return UpdateControl.patchStatus(resource).rescheduleAfter(TIMER_PERIOD); + } + + private void ensureStatusExists(EventSourceTestCustomResource resource) { + EventSourceTestCustomResourceStatus status = resource.getStatus(); + if (status == null) { + status = new EventSourceTestCustomResourceStatus(); + resource.setStatus(status); + } + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResource.java new file mode 100644 index 0000000000..b5f50df476 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResource.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.baseapi.event; + +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("Eventsourcesample") +@ShortNames("es") +public class EventSourceTestCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceSpec.java new file mode 100644 index 0000000000..203fd21440 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.event; + +public class EventSourceTestCustomResourceSpec { + + private String value; + + public String getValue() { + return value; + } + + public EventSourceTestCustomResourceSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceStatus.java new file mode 100644 index 0000000000..c602dd5db4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceStatus.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.baseapi.event; + +public class EventSourceTestCustomResourceStatus { + + private State state; + + public State getState() { + return state; + } + + public EventSourceTestCustomResourceStatus setState(State state) { + this.state = state; + return this; + } + + public enum State { + SUCCESS, + ERROR + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterIT.java new file mode 100644 index 0000000000..1e51ad5ab0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterIT.java @@ -0,0 +1,89 @@ +package io.javaoperatorsdk.operator.baseapi.filter; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.filter.FilterTestReconciler.CONFIG_MAP_FILTER_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class FilterIT { + + public static final String RESOURCE_NAME = "test1"; + public static final int POLL_DELAY = 150; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(FilterTestReconciler.class).build(); + + @Test + void filtersControllerResourceUpdate() { + var res = operator.create(createResource()); + // One for CR create event other for ConfigMap event + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted( + () -> + assertThat( + operator + .getReconcilerOfType(FilterTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(2)); + + res.getSpec().setValue(FilterTestReconciler.CUSTOM_RESOURCE_FILTER_VALUE); + operator.replace(res); + + // not more reconciliation with the filtered value + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted( + () -> + assertThat( + operator + .getReconcilerOfType(FilterTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(2)); + } + + @Test + void filtersSecondaryResourceUpdate() { + var res = operator.create(createResource()); + // One for CR create event other for ConfigMap event + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted( + () -> + assertThat( + operator + .getReconcilerOfType(FilterTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(2)); + + res.getSpec().setValue(CONFIG_MAP_FILTER_VALUE); + operator.replace(res); + + // the CM event filtered out + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted( + () -> + assertThat( + operator + .getReconcilerOfType(FilterTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(3)); + } + + FilterTestCustomResource createResource() { + FilterTestCustomResource resource = new FilterTestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + resource.setSpec(new FilterTestResourceSpec()); + resource.getSpec().setValue("value1"); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestCustomResource.java new file mode 100644 index 0000000000..83a34deeb9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestCustomResource.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.baseapi.filter; + +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("ftc") +public class FilterTestCustomResource + extends CustomResource implements Namespaced { + + public String getConfigMapName(int id) { + return "configmap" + id; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestReconciler.java new file mode 100644 index 0000000000..eeb988b143 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestReconciler.java @@ -0,0 +1,72 @@ +package io.javaoperatorsdk.operator.baseapi.filter; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration(informer = @Informer(onUpdateFilter = UpdateFilter.class)) +public class FilterTestReconciler implements Reconciler { + + public static final String CONFIG_MAP_FILTER_VALUE = "config_map_skip_this"; + public static final String CUSTOM_RESOURCE_FILTER_VALUE = "custom_resource_skip_this"; + + public static final String CM_VALUE_KEY = "value"; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + FilterTestCustomResource resource, Context context) { + numberOfExecutions.addAndGet(1); + context + .getClient() + .configMaps() + .inNamespace(resource.getMetadata().getNamespace()) + .resource(createConfigMap(resource)) + .createOrReplace(); + return UpdateControl.noUpdate(); + } + + private ConfigMap createConfigMap(FilterTestCustomResource resource) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .build()); + configMap.addOwnerReference(resource); + configMap.setData(Map.of(CM_VALUE_KEY, resource.getSpec().getValue())); + return configMap; + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + final var informerConfiguration = + InformerEventSourceConfiguration.from(ConfigMap.class, FilterTestCustomResource.class) + .withOnUpdateFilter( + (newCM, oldCM) -> + !newCM.getData().get(CM_VALUE_KEY).equals(CONFIG_MAP_FILTER_VALUE)) + .build(); + InformerEventSource configMapES = + new InformerEventSource<>(informerConfiguration, context); + + return List.of(configMapES); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestResourceSpec.java new file mode 100644 index 0000000000..1b4cebbfb6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestResourceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.filter; + +public class FilterTestResourceSpec { + + private String value; + + public String getValue() { + return value; + } + + public FilterTestResourceSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestResourceStatus.java new file mode 100644 index 0000000000..9d72abadf7 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/FilterTestResourceStatus.java @@ -0,0 +1,3 @@ +package io.javaoperatorsdk.operator.baseapi.filter; + +public class FilterTestResourceStatus {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/UpdateFilter.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/UpdateFilter.java new file mode 100644 index 0000000000..62d7ceaa76 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/UpdateFilter.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.baseapi.filter; + +import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; + +import static io.javaoperatorsdk.operator.baseapi.filter.FilterTestReconciler.CUSTOM_RESOURCE_FILTER_VALUE; + +public class UpdateFilter implements OnUpdateFilter { + @Override + public boolean accept(FilterTestCustomResource resource, FilterTestCustomResource oldResource) { + return !resource.getSpec().getValue().equals(CUSTOM_RESOURCE_FILTER_VALUE); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingCustomResource.java new file mode 100644 index 0000000000..07baef441a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.generickubernetesresourcehandling; + +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; +import io.javaoperatorsdk.operator.dependent.generickubernetesresource.GenericKubernetesDependentSpec; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("gkrr") +public class GenericKubernetesResourceHandlingCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingIT.java new file mode 100644 index 0000000000..3b2fccfe59 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingIT.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.baseapi.generickubernetesresourcehandling; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.dependent.generickubernetesresource.GenericKubernetesDependentSpec; +import io.javaoperatorsdk.operator.dependent.generickubernetesresource.GenericKubernetesDependentTestBase; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +public class GenericKubernetesResourceHandlingIT + extends GenericKubernetesDependentTestBase { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new GenericKubernetesResourceHandlingReconciler()) + .build(); + + @Override + public LocallyRunOperatorExtension extension() { + return extension; + } + + @Override + public GenericKubernetesResourceHandlingCustomResource testResource(String name, String data) { + var resource = new GenericKubernetesResourceHandlingCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(name).build()); + resource.setSpec(new GenericKubernetesDependentSpec()); + resource.getSpec().setValue(INITIAL_DATA); + return resource; + } +} 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 new file mode 100644 index 0000000000..fda6833afa --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/generickubernetesresourcehandling/GenericKubernetesResourceHandlingReconciler.java @@ -0,0 +1,89 @@ +package io.javaoperatorsdk.operator.baseapi.generickubernetesresourcehandling; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration +public class GenericKubernetesResourceHandlingReconciler + implements Reconciler { + + public static final String VERSION = "v1"; + public static final String KIND = "ConfigMap"; + public static final String KEY = "key"; + + @Override + 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()); + + 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) { + try (InputStream is = this.getClass().getResourceAsStream("/configmap.yaml")) { + var res = context.getClient().genericKubernetesResources(VERSION, KIND).load(is).item(); + res.getMetadata().setName(primary.getMetadata().getName()); + res.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + Map data = (Map) res.getAdditionalProperties().get("data"); + data.put(KEY, primary.getSpec().getValue()); + res.addOwnerReference(primary); + return res; + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + var informerEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + new GroupVersionKind("", VERSION, KIND), + GenericKubernetesResourceHandlingCustomResource.class) + .build(), + context); + + return List.of(informerEventSource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopIT.java new file mode 100644 index 0000000000..4f499b256b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopIT.java @@ -0,0 +1,81 @@ +package io.javaoperatorsdk.operator.baseapi.gracefulstop; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.gracefulstop.GracefulStopTestReconciler.RECONCILER_SLEEP; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class GracefulStopIT { + + public static final String TEST_1 = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withConfigurationService( + o -> + o.withCloseClientOnStop(false) + .withReconciliationTerminationTimeout(Duration.ofMillis(RECONCILER_SLEEP))) + .withReconciler(new GracefulStopTestReconciler()) + .build(); + + @Test + void stopsGracefullyWithTimeoutConfiguration() { + testGracefulStop(TEST_1, 2); + } + + private void testGracefulStop(String resourceName, int expectedFinalGeneration) { + var testRes = operator.create(testResource(resourceName)); + await() + .untilAsserted( + () -> { + var r = operator.get(GracefulStopTestCustomResource.class, resourceName); + assertThat(r.getStatus()).isNotNull(); + assertThat(r.getStatus().getObservedGeneration()).isEqualTo(1); + assertThat( + operator + .getReconcilerOfType(GracefulStopTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(1); + }); + + testRes.getSpec().setValue(2); + operator.replace(testRes); + + await() + .pollDelay(Duration.ofMillis(50)) + .untilAsserted( + () -> + assertThat( + operator + .getReconcilerOfType(GracefulStopTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(2)); + + operator.getOperator().stop(); + + await() + .untilAsserted( + () -> { + var r = operator.get(GracefulStopTestCustomResource.class, resourceName); + assertThat(r.getStatus()).isNotNull(); + assertThat(r.getStatus().getObservedGeneration()).isEqualTo(expectedFinalGeneration); + }); + } + + public GracefulStopTestCustomResource testResource(String name) { + GracefulStopTestCustomResource resource = new GracefulStopTestCustomResource(); + resource.setMetadata( + new ObjectMetaBuilder().withName(name).withNamespace(operator.getNamespace()).build()); + resource.setSpec(new GracefulStopTestCustomResourceSpec()); + resource.getSpec().setValue(1); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestCustomResource.java new file mode 100644 index 0000000000..3c21246c41 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.gracefulstop; + +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("gst") +public class GracefulStopTestCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestCustomResourceSpec.java new file mode 100644 index 0000000000..f1c4b42af2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestCustomResourceSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.gracefulstop; + +public class GracefulStopTestCustomResourceSpec { + + private int value; + + public int getValue() { + return value; + } + + public void setValue(int value) { + this.value = value; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestCustomResourceStatus.java new file mode 100644 index 0000000000..3e4e992399 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestCustomResourceStatus.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.gracefulstop; + +public class GracefulStopTestCustomResourceStatus { + + private long observedGeneration; + + public long getObservedGeneration() { + return observedGeneration; + } + + public void setObservedGeneration(long observedGeneration) { + this.observedGeneration = observedGeneration; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestReconciler.java new file mode 100644 index 0000000000..64c527196e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/gracefulstop/GracefulStopTestReconciler.java @@ -0,0 +1,33 @@ +package io.javaoperatorsdk.operator.baseapi.gracefulstop; + +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; + +@ControllerConfiguration +public class GracefulStopTestReconciler implements Reconciler { + + public static final int RECONCILER_SLEEP = 1000; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + GracefulStopTestCustomResource resource, Context context) + throws InterruptedException { + + numberOfExecutions.addAndGet(1); + resource.setStatus(new GracefulStopTestCustomResourceStatus()); + resource.getStatus().setObservedGeneration(resource.getMetadata().getGeneration()); + Thread.sleep(RECONCILER_SLEEP); + + return UpdateControl.patchStatus(resource); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceIT.java new file mode 100644 index 0000000000..c1a857c22c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceIT.java @@ -0,0 +1,97 @@ +package io.javaoperatorsdk.operator.baseapi.informereventsource; + +import java.util.HashMap; +import java.util.concurrent.TimeUnit; + +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.ObjectMeta; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.informereventsource.InformerEventSourceTestCustomReconciler.MISSING_CONFIG_MAP; +import static io.javaoperatorsdk.operator.baseapi.informereventsource.InformerEventSourceTestCustomReconciler.RELATED_RESOURCE_NAME; +import static io.javaoperatorsdk.operator.baseapi.informereventsource.InformerEventSourceTestCustomReconciler.TARGET_CONFIG_MAP_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.awaitility.Awaitility.await; + +class InformerEventSourceIT { + + public static final String RESOURCE_NAME = "informertestcr"; + public static final String INITIAL_STATUS_MESSAGE = "Initial Status"; + public static final String UPDATE_STATUS_MESSAGE = "Updated Status"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new InformerEventSourceTestCustomReconciler()) + .build(); + + @Test + void testUsingInformerToWatchChangesOfConfigMap() { + var customResource = initialCustomResource(); + customResource = operator.create(customResource); + ConfigMap configMap = operator.create(relatedConfigMap(customResource.getMetadata().getName())); + waitForCRStatusValue(INITIAL_STATUS_MESSAGE); + + configMap.getData().put(TARGET_CONFIG_MAP_KEY, UPDATE_STATUS_MESSAGE); + operator.replace(configMap); + + waitForCRStatusValue(UPDATE_STATUS_MESSAGE); + } + + @Test + void deletingSecondaryResource() { + var customResource = initialCustomResource(); + customResource = operator.create(customResource); + waitForCRStatusValue(MISSING_CONFIG_MAP); + ConfigMap configMap = operator.create(relatedConfigMap(customResource.getMetadata().getName())); + waitForCRStatusValue(INITIAL_STATUS_MESSAGE); + + boolean res = operator.delete(configMap); + if (!res) { + fail("Unable to delete configmap"); + } + + waitForCRStatusValue(MISSING_CONFIG_MAP); + assertThat( + ((InformerEventSourceTestCustomReconciler) operator.getReconcilers().get(0)) + .getNumberOfExecutions()) + .isEqualTo(3); + } + + private ConfigMap relatedConfigMap(String relatedResourceAnnotation) { + ConfigMap configMap = new ConfigMap(); + + ObjectMeta objectMeta = new ObjectMeta(); + objectMeta.setName(RESOURCE_NAME); + objectMeta.setAnnotations(new HashMap<>()); + objectMeta.getAnnotations().put(RELATED_RESOURCE_NAME, relatedResourceAnnotation); + configMap.setMetadata(objectMeta); + + configMap.setData(new HashMap<>()); + configMap.getData().put(TARGET_CONFIG_MAP_KEY, INITIAL_STATUS_MESSAGE); + return configMap; + } + + private InformerEventSourceTestCustomResource initialCustomResource() { + var customResource = new InformerEventSourceTestCustomResource(); + ObjectMeta objectMeta = new ObjectMeta(); + objectMeta.setName(RESOURCE_NAME); + customResource.setMetadata(objectMeta); + return customResource; + } + + private void waitForCRStatusValue(String value) { + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> { + var cr = operator.get(InformerEventSourceTestCustomResource.class, RESOURCE_NAME); + assertThat(cr.getStatus()).isNotNull(); + assertThat(cr.getStatus().getConfigMapValue()).isEqualTo(value); + }); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomReconciler.java new file mode 100644 index 0000000000..1f0ecccb8c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomReconciler.java @@ -0,0 +1,76 @@ +package io.javaoperatorsdk.operator.baseapi.informereventsource; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ConfigMap; +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.processing.event.source.informer.Mappers; + +/** + * Copies the config map value from spec into status. The main purpose is to test and demonstrate + * sample usage of InformerEventSource + */ +@ControllerConfiguration +public class InformerEventSourceTestCustomReconciler + implements Reconciler { + + private static final Logger LOGGER = + LoggerFactory.getLogger(InformerEventSourceTestCustomReconciler.class); + + public static final String RELATED_RESOURCE_NAME = "relatedResourceName"; + public static final String RELATED_RESOURCE_TYPE = "relatedResourceType"; + public static final String TARGET_CONFIG_MAP_KEY = "targetStatus"; + public static final String MISSING_CONFIG_MAP = "Missing Config Map"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + InformerEventSourceConfiguration config = + InformerEventSourceConfiguration.from( + ConfigMap.class, InformerEventSourceTestCustomResource.class) + .withSecondaryToPrimaryMapper( + Mappers.fromAnnotation( + RELATED_RESOURCE_NAME, + RELATED_RESOURCE_TYPE, + InformerEventSourceTestCustomResource.class)) + .build(); + + return List.of(new InformerEventSource<>(config, context)); + } + + @Override + public UpdateControl reconcile( + InformerEventSourceTestCustomResource resource, + Context context) { + numberOfExecutions.incrementAndGet(); + + resource.setStatus(new InformerEventSourceTestCustomResourceStatus()); + // Reading the config map from the informer not from the API + // name of the config map same as custom resource for sake of simplicity + Optional configMap = context.getSecondaryResource(ConfigMap.class); + if (configMap.isEmpty()) { + resource.getStatus().setConfigMapValue(MISSING_CONFIG_MAP); + } else { + String targetStatus = configMap.get().getData().get(TARGET_CONFIG_MAP_KEY); + LOGGER.debug("Setting target status for CR: {}", targetStatus); + resource.getStatus().setConfigMapValue(targetStatus); + } + + return UpdateControl.patchStatus(resource); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomResource.java new file mode 100644 index 0000000000..cb63ac4754 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomResource.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.baseapi.informereventsource; + +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("Informereventsourcesample") +@ShortNames("ies") +public class InformerEventSourceTestCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomResourceStatus.java new file mode 100644 index 0000000000..529b53db13 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomResourceStatus.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.informereventsource; + +public class InformerEventSourceTestCustomResourceStatus { + + private String configMapValue; + + public String getConfigMapValue() { + return configMapValue; + } + + public InformerEventSourceTestCustomResourceStatus setConfigMapValue(String configMapValue) { + this.configMapValue = configMapValue; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterCustomResource.java new file mode 100644 index 0000000000..79011f1448 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.informerremotecluster; + +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("irc") +public class InformerRemoteClusterCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterIT.java new file mode 100644 index 0000000000..3cd5ddccbc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterIT.java @@ -0,0 +1,97 @@ +package io.javaoperatorsdk.operator.baseapi.informerremotecluster; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubeapitest.junit.EnableKubeAPIServer; +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers; + +import static io.javaoperatorsdk.operator.baseapi.informerremotecluster.InformerRemoteClusterReconciler.DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@EnableKubeAPIServer +class InformerRemoteClusterIT { + + public static final String NAME = "test1"; + public static final String CONFIG_MAP_NAME = "testcm"; + public static final String INITIAL_VALUE = "initial_value"; + public static final String CHANGED_VALUE = "changed_value"; + public static final String CM_NAMESPACE = "default"; + + // injected by Kube API Test. Client for another cluster. + static KubernetesClient kubernetesClient; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new InformerRemoteClusterReconciler(kubernetesClient)) + .build(); + + @Test + void testRemoteClusterInformer() { + var r = extension.create(testCustomResource()); + + var cm = + kubernetesClient + .configMaps() + .resource(remoteConfigMap(r.getMetadata().getName(), r.getMetadata().getNamespace())) + .create(); + + // config map does not exist on the primary resource cluster + assertThat( + extension + .getKubernetesClient() + .configMaps() + .inNamespace(CM_NAMESPACE) + .withName(CONFIG_MAP_NAME) + .get()) + .isNull(); + + await() + .untilAsserted( + () -> { + var cr = extension.get(InformerRemoteClusterCustomResource.class, NAME); + assertThat(cr.getStatus()).isNotNull(); + assertThat(cr.getStatus().getRemoteConfigMapMessage()).isEqualTo(INITIAL_VALUE); + }); + + cm.getData().put(DATA_KEY, CHANGED_VALUE); + kubernetesClient.configMaps().resource(cm).update(); + + await() + .untilAsserted( + () -> { + var cr = extension.get(InformerRemoteClusterCustomResource.class, NAME); + assertThat(cr.getStatus().getRemoteConfigMapMessage()).isEqualTo(CHANGED_VALUE); + }); + } + + InformerRemoteClusterCustomResource testCustomResource() { + var res = new InformerRemoteClusterCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(NAME).build()); + return res; + } + + ConfigMap remoteConfigMap(String ownerName, String ownerNamespace) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(CONFIG_MAP_NAME) + .withNamespace(CM_NAMESPACE) + .withAnnotations( + Map.of( + Mappers.DEFAULT_ANNOTATION_FOR_NAME, ownerName, + Mappers.DEFAULT_ANNOTATION_FOR_NAMESPACE, ownerNamespace)) + .build()) + .withData(Map.of(DATA_KEY, INITIAL_VALUE)) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterReconciler.java new file mode 100644 index 0000000000..08c861df96 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterReconciler.java @@ -0,0 +1,73 @@ +package io.javaoperatorsdk.operator.baseapi.informerremotecluster; + +import java.util.List; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers; + +@ControllerConfiguration +public class InformerRemoteClusterReconciler + implements Reconciler { + + public static final String DATA_KEY = "key"; + + private final KubernetesClient remoteClient; + + public InformerRemoteClusterReconciler(KubernetesClient remoteClient) { + this.remoteClient = remoteClient; + } + + @Override + public UpdateControl reconcile( + InformerRemoteClusterCustomResource resource, + Context context) + throws Exception { + + return context + .getSecondaryResource(ConfigMap.class) + .map( + cm -> { + var r = new InformerRemoteClusterCustomResource(); + r.setMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .build()); + r.setStatus(new InformerRemoteClusterStatus()); + r.getStatus().setRemoteConfigMapMessage(cm.getData().get(DATA_KEY)); + return UpdateControl.patchStatus(r); + }) + .orElseGet(UpdateControl::noUpdate); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + var es = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, InformerRemoteClusterCustomResource.class) + // owner references do not work cross cluster, using + // annotations here to reference primary resource + .withSecondaryToPrimaryMapper( + Mappers.fromDefaultAnnotations(InformerRemoteClusterCustomResource.class)) + // setting remote client for informer + .withKubernetesClient(remoteClient) + .withWatchAllNamespaces() + .build(), + context); + + return List.of(es); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterStatus.java new file mode 100644 index 0000000000..e49322fb10 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informerremotecluster/InformerRemoteClusterStatus.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.informerremotecluster; + +public class InformerRemoteClusterStatus { + + private String remoteConfigMapMessage; + + public String getRemoteConfigMapMessage() { + return remoteConfigMapMessage; + } + + public void setRemoteConfigMapMessage(String remoteConfigMapMessage) { + this.remoteConfigMapMessage = remoteConfigMapMessage; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/labelselector/LabelSelectorIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/labelselector/LabelSelectorIT.java new file mode 100644 index 0000000000..b073bee248 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/labelselector/LabelSelectorIT.java @@ -0,0 +1,52 @@ +package io.javaoperatorsdk.operator.baseapi.labelselector; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.labelselector.LabelSelectorTestReconciler.LABEL_KEY; +import static io.javaoperatorsdk.operator.baseapi.labelselector.LabelSelectorTestReconciler.LABEL_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class LabelSelectorIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new LabelSelectorTestReconciler()) + .build(); + + @Test + void filtersCustomResourceByLabel() { + operator.create(resource("r1", true)); + operator.create(resource("r2", false)); + + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(LabelSelectorTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(1); + }); + } + + LabelSelectorTestCustomResource resource(String name, boolean addLabel) { + var res = new LabelSelectorTestCustomResource(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(name) + .withLabels(addLabel ? Map.of(LABEL_KEY, LABEL_VALUE) : Collections.emptyMap()) + .build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/labelselector/LabelSelectorTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/labelselector/LabelSelectorTestCustomResource.java new file mode 100644 index 0000000000..6659af0404 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/labelselector/LabelSelectorTestCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.labelselector; + +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("lst") +public class LabelSelectorTestCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/labelselector/LabelSelectorTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/labelselector/LabelSelectorTestReconciler.java new file mode 100644 index 0000000000..4800f758fb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/labelselector/LabelSelectorTestReconciler.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.baseapi.labelselector; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +import static io.javaoperatorsdk.operator.baseapi.labelselector.LabelSelectorTestReconciler.LABEL_KEY; +import static io.javaoperatorsdk.operator.baseapi.labelselector.LabelSelectorTestReconciler.LABEL_VALUE; + +@ControllerConfiguration(informer = @Informer(labelSelector = LABEL_KEY + "=" + LABEL_VALUE)) +public class LabelSelectorTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + public static final String LABEL_KEY = "app"; + public static final String LABEL_VALUE = "myapp"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + LabelSelectorTestCustomResource resource, Context context) { + + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionchangenamespace/LeaderElectionChangeNamespaceCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionchangenamespace/LeaderElectionChangeNamespaceCustomResource.java new file mode 100644 index 0000000000..421ab2b5ce --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionchangenamespace/LeaderElectionChangeNamespaceCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.leaderelectionchangenamespace; + +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("lcn") +public class LeaderElectionChangeNamespaceCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionchangenamespace/LeaderElectionChangeNamespaceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionchangenamespace/LeaderElectionChangeNamespaceIT.java new file mode 100644 index 0000000000..f6194439e2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionchangenamespace/LeaderElectionChangeNamespaceIT.java @@ -0,0 +1,97 @@ +package io.javaoperatorsdk.operator.baseapi.leaderelectionchangenamespace; + +import java.time.Duration; +import java.time.ZonedDateTime; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.coordination.v1.Lease; +import io.fabric8.kubernetes.api.model.coordination.v1.LeaseSpecBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class LeaderElectionChangeNamespaceIT { + + public static final String LEASE_NAME = "nschangelease"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withConfigurationService( + o -> o.withLeaderElectionConfiguration(new LeaderElectionConfiguration(LEASE_NAME))) + .withReconciler(new LeaderElectionChangeNamespaceReconciler()) + .build(); + + private static KubernetesClient client = new KubernetesClientBuilder().build(); + + @BeforeAll + static void createLeaseManually() { + client.resource(lease()).create(); + } + + @AfterAll + static void deleteLeaseManually() { + client.resource(lease()).delete(); + } + + @Test + @DisplayName("If operator is not a leader, namespace change should not start processor") + void noReconcileOnChangeNamespace() { + extension.create(testResource()); + + var reconciler = extension.getReconcilerOfType(LeaderElectionChangeNamespaceReconciler.class); + await() + .pollDelay(Duration.ofSeconds(1)) + .timeout(Duration.ofSeconds(3)) + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions()).isEqualTo(0); + }); + + extension + .getRegisteredControllerForReconcile(LeaderElectionChangeNamespaceReconciler.class) + .changeNamespaces("default", extension.getNamespace()); + + await() + .pollDelay(Duration.ofSeconds(1)) + .timeout(Duration.ofSeconds(3)) + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions()).isEqualTo(0); + }); + } + + LeaderElectionChangeNamespaceCustomResource testResource() { + var resource = new LeaderElectionChangeNamespaceCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName("test1").build()); + return resource; + } + + static Lease lease() { + var lease = new Lease(); + lease.setMetadata( + new ObjectMetaBuilder().withName(LEASE_NAME).withNamespace("default").build()); + var time = ZonedDateTime.now(); + lease.setSpec( + new LeaseSpecBuilder() + .withAcquireTime(ZonedDateTime.now()) + .withRenewTime(time) + .withAcquireTime(time) + .withHolderIdentity("non-operator-identity") + .withLeaseTransitions(0) + .withLeaseDurationSeconds(30) + .build()); + + return lease; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionchangenamespace/LeaderElectionChangeNamespaceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionchangenamespace/LeaderElectionChangeNamespaceReconciler.java new file mode 100644 index 0000000000..c98900a694 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/leaderelectionchangenamespace/LeaderElectionChangeNamespaceReconciler.java @@ -0,0 +1,28 @@ +package io.javaoperatorsdk.operator.baseapi.leaderelectionchangenamespace; + +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; + +@ControllerConfiguration() +public class LeaderElectionChangeNamespaceReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + LeaderElectionChangeNamespaceCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationCustomResource.java new file mode 100644 index 0000000000..386940857f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.manualobservedgeneration; + +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("mog") +public class ManualObservedGenerationCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationIT.java new file mode 100644 index 0000000000..f90fda5c5e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationIT.java @@ -0,0 +1,57 @@ +package io.javaoperatorsdk.operator.baseapi.manualobservedgeneration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class ManualObservedGenerationIT { + + public static final String RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new ManualObservedGenerationReconciler()) + .build(); + + @Test + void observedGenerationUpdated() { + extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var r = extension.get(ManualObservedGenerationCustomResource.class, RESOURCE_NAME); + assertThat(r).isNotNull(); + assertThat(r.getStatus().getObservedGeneration()).isEqualTo(1); + assertThat(r.getStatus().getObservedGeneration()) + .isEqualTo(r.getMetadata().getGeneration()); + }); + + var changed = testResource(); + changed.getSpec().setValue("changed value"); + extension.replace(changed); + + await() + .untilAsserted( + () -> { + var r = extension.get(ManualObservedGenerationCustomResource.class, RESOURCE_NAME); + assertThat(r.getStatus().getObservedGeneration()).isEqualTo(2); + assertThat(r.getStatus().getObservedGeneration()) + .isEqualTo(r.getMetadata().getGeneration()); + }); + } + + ManualObservedGenerationCustomResource testResource() { + var res = new ManualObservedGenerationCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + res.setSpec(new ManualObservedGenerationSpec()); + res.getSpec().setValue("Initial Value"); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationReconciler.java new file mode 100644 index 0000000000..f69ac6a37b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationReconciler.java @@ -0,0 +1,51 @@ +package io.javaoperatorsdk.operator.baseapi.manualobservedgeneration; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.*; + +@ControllerConfiguration +public class ManualObservedGenerationReconciler + implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + ManualObservedGenerationCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + var resourceForStatusPatch = resourceForStatusPatch(resource); + if (!Objects.equals( + resource.getMetadata().getGeneration(), + resourceForStatusPatch.getStatus().getObservedGeneration())) { + resourceForStatusPatch + .getStatus() + .setObservedGeneration(resource.getMetadata().getGeneration()); + return UpdateControl.patchStatus(resourceForStatusPatch); + } else { + return UpdateControl.noUpdate(); + } + } + + private ManualObservedGenerationCustomResource resourceForStatusPatch( + ManualObservedGenerationCustomResource original) { + var res = new ManualObservedGenerationCustomResource(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(original.getMetadata().getName()) + .withNamespace(original.getMetadata().getNamespace()) + .build()); + res.setStatus(original.getStatus()); + if (res.getStatus() == null) { + res.setStatus(new ManualObservedGenerationStatus()); + } + return res; + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationSpec.java new file mode 100644 index 0000000000..4c7076efd1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.manualobservedgeneration; + +public class ManualObservedGenerationSpec { + + 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/manualobservedgeneration/ManualObservedGenerationStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationStatus.java new file mode 100644 index 0000000000..b70a4f18da --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationStatus.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.manualobservedgeneration; + +public class ManualObservedGenerationStatus { + + private long observedGeneration; + + public long getObservedGeneration() { + return observedGeneration; + } + + public void setObservedGeneration(long observedGeneration) { + this.observedGeneration = observedGeneration; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxinterval/MaxIntervalIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxinterval/MaxIntervalIT.java new file mode 100644 index 0000000000..af7cdbe8ec --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxinterval/MaxIntervalIT.java @@ -0,0 +1,44 @@ +package io.javaoperatorsdk.operator.baseapi.maxinterval; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class MaxIntervalIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(new MaxIntervalTestReconciler()).build(); + + @Test + void reconciliationTriggeredBasedOnMaxInterval() { + MaxIntervalTestCustomResource cr = createTestResource(); + + operator.create(cr); + + await() + .pollInterval(50, TimeUnit.MILLISECONDS) + .atMost(500, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> + assertThat( + operator + .getReconcilerOfType(MaxIntervalTestReconciler.class) + .getNumberOfExecutions()) + .isGreaterThan(3)); + } + + private MaxIntervalTestCustomResource createTestResource() { + MaxIntervalTestCustomResource cr = new MaxIntervalTestCustomResource(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName("maxintervaltest1"); + return cr; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxinterval/MaxIntervalTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxinterval/MaxIntervalTestCustomResource.java new file mode 100644 index 0000000000..ad4af2df74 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxinterval/MaxIntervalTestCustomResource.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.maxinterval; + +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("MaxIntervalTestCustomResource") +@ShortNames("mit") +public class MaxIntervalTestCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxinterval/MaxIntervalTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxinterval/MaxIntervalTestReconciler.java new file mode 100644 index 0000000000..d017a9d2e1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxinterval/MaxIntervalTestReconciler.java @@ -0,0 +1,31 @@ +package io.javaoperatorsdk.operator.baseapi.maxinterval; + +import java.util.concurrent.TimeUnit; +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.MaxReconciliationInterval; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration( + maxReconciliationInterval = + @MaxReconciliationInterval(interval = 50, timeUnit = TimeUnit.MILLISECONDS)) +public class MaxIntervalTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + MaxIntervalTestCustomResource resource, Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxintervalafterretry/MaxIntervalAfterRetryIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxintervalafterretry/MaxIntervalAfterRetryIT.java new file mode 100644 index 0000000000..bc55fa6035 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxintervalafterretry/MaxIntervalAfterRetryIT.java @@ -0,0 +1,46 @@ +package io.javaoperatorsdk.operator.baseapi.maxintervalafterretry; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class MaxIntervalAfterRetryIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new MaxIntervalAfterRetryTestReconciler()) + .build(); + + @Test + void reconciliationTriggeredBasedOnMaxInterval() { + MaxIntervalAfterRetryTestCustomResource cr = createTestResource(); + + operator.create(cr); + + await() + .pollInterval(50, TimeUnit.MILLISECONDS) + .atMost(1, TimeUnit.SECONDS) + .untilAsserted( + () -> + assertThat( + operator + .getReconcilerOfType(MaxIntervalAfterRetryTestReconciler.class) + .getNumberOfExecutions()) + .isGreaterThan(5)); + } + + private MaxIntervalAfterRetryTestCustomResource createTestResource() { + MaxIntervalAfterRetryTestCustomResource cr = new MaxIntervalAfterRetryTestCustomResource(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName("maxintervalretrytest1"); + return cr; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxintervalafterretry/MaxIntervalAfterRetryTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxintervalafterretry/MaxIntervalAfterRetryTestCustomResource.java new file mode 100644 index 0000000000..b854d05560 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxintervalafterretry/MaxIntervalAfterRetryTestCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.maxintervalafterretry; + +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("mir") +public class MaxIntervalAfterRetryTestCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxintervalafterretry/MaxIntervalAfterRetryTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxintervalafterretry/MaxIntervalAfterRetryTestReconciler.java new file mode 100644 index 0000000000..ec1bbc99d5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/maxintervalafterretry/MaxIntervalAfterRetryTestReconciler.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.baseapi.maxintervalafterretry; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.processing.retry.GradualRetry; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@GradualRetry(maxAttempts = 1, initialInterval = 100) +@ControllerConfiguration( + maxReconciliationInterval = + @MaxReconciliationInterval(interval = 50, timeUnit = TimeUnit.MILLISECONDS)) +public class MaxIntervalAfterRetryTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private static final Logger log = + LoggerFactory.getLogger(MaxIntervalAfterRetryTestReconciler.class); + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + MaxIntervalAfterRetryTestCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + log.info("Executed, number: {}", numberOfExecutions.get()); + throw new RuntimeException(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeCustomResource.java new file mode 100644 index 0000000000..1d8062366f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.multiplereconcilersametype; + +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("mrst") +public class MultipleReconcilerSameTypeCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeIT.java new file mode 100644 index 0000000000..81cfa7fd07 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeIT.java @@ -0,0 +1,64 @@ +package io.javaoperatorsdk.operator.baseapi.multiplereconcilersametype; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class MultipleReconcilerSameTypeIT { + + public static final String TEST_RESOURCE_1 = "test1"; + public static final String TEST_RESOURCE_2 = "test2"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(MultipleReconcilerSameTypeReconciler1.class) + .withReconciler(MultipleReconcilerSameTypeReconciler2.class) + .build(); + + @Test + void multipleReconcilersBasedOnLeaderElection() { + extension.create(testResource(TEST_RESOURCE_1, true)); + extension.create(testResource(TEST_RESOURCE_2, false)); + + await() + .untilAsserted( + () -> { + assertThat( + extension + .getReconcilerOfType(MultipleReconcilerSameTypeReconciler1.class) + .getNumberOfExecutions()) + .isEqualTo(1); + assertThat( + extension + .getReconcilerOfType(MultipleReconcilerSameTypeReconciler2.class) + .getNumberOfExecutions()) + .isEqualTo(1); + + var res1 = + extension.get(MultipleReconcilerSameTypeCustomResource.class, TEST_RESOURCE_1); + var res2 = + extension.get(MultipleReconcilerSameTypeCustomResource.class, TEST_RESOURCE_2); + assertThat(res1).isNotNull(); + assertThat(res2).isNotNull(); + assertThat(res1.getStatus().getReconciledBy()) + .isEqualTo(MultipleReconcilerSameTypeReconciler1.class.getSimpleName()); + assertThat(res2.getStatus().getReconciledBy()) + .isEqualTo(MultipleReconcilerSameTypeReconciler2.class.getSimpleName()); + }); + } + + MultipleReconcilerSameTypeCustomResource testResource(String name, boolean type1) { + var res = new MultipleReconcilerSameTypeCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(name).build()); + if (type1) { + res.getMetadata().getLabels().put("reconciler", "1"); + } + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeReconciler1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeReconciler1.java new file mode 100644 index 0000000000..42aa52f9e1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeReconciler1.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.baseapi.multiplereconcilersametype; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration(informer = @Informer(labelSelector = "reconciler = 1")) +public class MultipleReconcilerSameTypeReconciler1 + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + MultipleReconcilerSameTypeCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + + resource.setStatus(new MultipleReconcilerSameTypeStatus()); + resource.getStatus().setReconciledBy(getClass().getSimpleName()); + return UpdateControl.patchStatus(resource); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeReconciler2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeReconciler2.java new file mode 100644 index 0000000000..fc61f0624a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeReconciler2.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.baseapi.multiplereconcilersametype; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +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; + +@ControllerConfiguration(informer = @Informer(labelSelector = "reconciler != 1")) +public class MultipleReconcilerSameTypeReconciler2 + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + MultipleReconcilerSameTypeCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + + resource.setStatus(new MultipleReconcilerSameTypeStatus()); + resource.getStatus().setReconciledBy(getClass().getSimpleName()); + return UpdateControl.patchStatus(resource); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeStatus.java new file mode 100644 index 0000000000..e14dd2ea6b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplereconcilersametype/MultipleReconcilerSameTypeStatus.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.multiplereconcilersametype; + +public class MultipleReconcilerSameTypeStatus { + + private String reconciledBy; + + public String getReconciledBy() { + return reconciledBy; + } + + public void setReconciledBy(String reconciledBy) { + this.reconciledBy = reconciledBy; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceCustomResource.java new file mode 100644 index 0000000000..7c3f88ae74 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceCustomResource.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.multiplesecondaryeventsource; + +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("MultipleSecondaryEventSourceCustomResource") +@ShortNames("mses") +public class MultipleSecondaryEventSourceCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceIT.java new file mode 100644 index 0000000000..18a937040f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceIT.java @@ -0,0 +1,70 @@ +package io.javaoperatorsdk.operator.baseapi.multiplesecondaryeventsource; + +import java.time.Duration; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.awaitility.Awaitility.await; + +class MultipleSecondaryEventSourceIT { + + public static final String TEST_RESOURCE_NAME = "testresource"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(MultipleSecondaryEventSourceReconciler.class) + .build(); + + @Test + void receivingPeriodicEvents() { + MultipleSecondaryEventSourceCustomResource resource = createTestCustomResource(); + + operator.create(resource); + + var reconciler = operator.getReconcilerOfType(MultipleSecondaryEventSourceReconciler.class); + + await().pollDelay(Duration.ofMillis(300)).until(() -> reconciler.getNumberOfExecutions() <= 3); + + int numberOfInitialExecutions = reconciler.getNumberOfExecutions(); + + updateConfigMap(resource, 1); + + await() + .pollDelay(Duration.ofMillis(300)) + .until(() -> reconciler.getNumberOfExecutions() == numberOfInitialExecutions + 1); + + updateConfigMap(resource, 2); + + await() + .pollDelay(Duration.ofMillis(300)) + .until(() -> reconciler.getNumberOfExecutions() == numberOfInitialExecutions + 2); + } + + private void updateConfigMap(MultipleSecondaryEventSourceCustomResource resource, int number) { + ConfigMap map1 = + operator.get( + ConfigMap.class, + number == 1 + ? MultipleSecondaryEventSourceReconciler.getName1(resource) + : MultipleSecondaryEventSourceReconciler.getName2(resource)); + map1.getData().put("value2", "value2"); + operator.replace(map1); + } + + public MultipleSecondaryEventSourceCustomResource createTestCustomResource() { + MultipleSecondaryEventSourceCustomResource resource = + new MultipleSecondaryEventSourceCustomResource(); + resource.setMetadata( + new ObjectMetaBuilder() + .withName(TEST_RESOURCE_NAME) + .withNamespace(operator.getNamespace()) + .build()); + return resource; + } +} 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 new file mode 100644 index 0000000000..18432cc86b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiplesecondaryeventsource/MultipleSecondaryEventSourceReconciler.java @@ -0,0 +1,115 @@ +package io.javaoperatorsdk.operator.baseapi.multiplesecondaryeventsource; + +import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration +public class MultipleSecondaryEventSourceReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + public static String getName1(MultipleSecondaryEventSourceCustomResource resource) { + return resource.getMetadata().getName() + "1"; + } + + public static String getName2(MultipleSecondaryEventSourceCustomResource resource) { + return resource.getMetadata().getName() + "2"; + } + + @Override + public UpdateControl reconcile( + MultipleSecondaryEventSourceCustomResource resource, + 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(); + } + + if (numberOfExecutions.get() >= 3) { + if (context.getSecondaryResources(ConfigMap.class).size() != 2) { + throw new IllegalStateException("There should be 2 related config maps"); + } + } + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + var config = + InformerEventSourceConfiguration.from( + ConfigMap.class, MultipleSecondaryEventSourceCustomResource.class) + .withNamespacesInheritedFromController() + .withLabelSelector("multisecondary") + .withSecondaryToPrimaryMapper( + s -> { + var name = + s.getMetadata() + .getName() + .subSequence(0, s.getMetadata().getName().length() - 1); + return Set.of(new ResourceID(name.toString(), s.getMetadata().getNamespace())); + }) + .build(); + InformerEventSource + configMapEventSource = new InformerEventSource<>(config, context); + return List.of(configMapEventSource); + } + + ConfigMap configMap(String name, MultipleSecondaryEventSourceCustomResource resource) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new ObjectMeta()); + configMap.getMetadata().setName(name); + configMap.getMetadata().setNamespace(resource.getMetadata().getNamespace()); + configMap.setData(new HashMap<>()); + configMap.getData().put(name, name); + HashMap labels = new HashMap<>(); + labels.put("multisecondary", "true"); + configMap.getMetadata().setLabels(labels); + configMap.addOwnerReference(resource); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDIT.java new file mode 100644 index 0000000000..9e0c6eb1fb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDIT.java @@ -0,0 +1,208 @@ +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; + +import java.time.Duration; +import java.util.HashMap; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.client.WatcherException; +import io.fabric8.kubernetes.client.informers.SharedIndexInformer; +import io.fabric8.kubernetes.client.utils.Serialization; +import io.javaoperatorsdk.operator.api.config.InformerStoppedHandler; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class MultiVersionCRDIT { + + private static final Logger log = LoggerFactory.getLogger(MultiVersionCRDIT.class); + + public static final String CR_V1_NAME = "crv1"; + public static final String CR_V2_NAME = "crv2"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(MultiVersionCRDTestReconciler1.class) + .withReconciler(MultiVersionCRDTestReconciler2.class) + .withConfigurationService( + overrider -> overrider.withInformerStoppedHandler(informerStoppedHandler)) + .build(); + + private static class TestInformerStoppedHandler implements InformerStoppedHandler { + private volatile String resourceClassName; + private volatile String resourceCreateAsVersion; + + private volatile String failedResourceVersion; + private volatile String errorMessage; + + public void reset() { + resourceClassName = null; + resourceCreateAsVersion = null; + failedResourceVersion = null; + errorMessage = null; + } + + @Override + @SuppressWarnings("rawtypes") + public void onStop(SharedIndexInformer informer, Throwable ex) { + if (ex instanceof WatcherException watcherEx) { + watcherEx + .getRawWatchMessage() + .ifPresent( + raw -> { + try { + // extract the resource at which the version is attempted to be created (i.e. + // the stored + // version) + final var unmarshal = Serialization.jsonMapper().readTree(raw); + final var object = unmarshal.get("object"); + resourceCreateAsVersion = + acceptOnlyIfUnsetOrEqualToAlreadySet( + resourceCreateAsVersion, object.get("apiVersion").asText()); + // extract the asked resource version + failedResourceVersion = + acceptOnlyIfUnsetOrEqualToAlreadySet( + failedResourceVersion, + object + .get("metadata") + .get("managedFields") + .get(0) + .get("apiVersion") + .asText()); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + }); + + // extract error message + errorMessage = + acceptOnlyIfUnsetOrEqualToAlreadySet(errorMessage, watcherEx.getCause().getMessage()); + } + final var apiTypeClass = informer.getApiTypeClass(); + + log.debug("Current resourceClassName: " + resourceClassName); + + resourceClassName = + acceptOnlyIfUnsetOrEqualToAlreadySet(resourceClassName, apiTypeClass.getName()); + + log.debug( + "API Type Class: " + + apiTypeClass.getName() + + " - resource class name: " + + resourceClassName); + log.info( + "Informer for " + + HasMetadata.getFullResourceName(apiTypeClass) + + " stopped due to: " + + ex.getMessage()); + } + + public String getResourceClassName() { + return resourceClassName; + } + + public String getResourceCreateAsVersion() { + return resourceCreateAsVersion; + } + + public String getErrorMessage() { + return errorMessage; + } + + public String getFailedResourceVersion() { + return failedResourceVersion; + } + + private String acceptOnlyIfUnsetOrEqualToAlreadySet(String existing, String newValue) { + return (existing == null || existing.equals(newValue)) ? newValue : null; + } + } + + private static final TestInformerStoppedHandler informerStoppedHandler = + new TestInformerStoppedHandler(); + + @Test + void multipleCRDVersions() { + informerStoppedHandler.reset(); + operator.create(createTestResourceV1WithoutLabel()); + operator.create(createTestResourceV2WithLabel()); + + await() + .atMost(Duration.ofSeconds(2)) + .pollInterval(Duration.ofMillis(50)) + .untilAsserted( + () -> { + var crV1Now = operator.get(MultiVersionCRDTestCustomResource1.class, CR_V1_NAME); + var crV2Now = operator.get(MultiVersionCRDTestCustomResource2.class, CR_V2_NAME); + assertThat(crV1Now.getStatus()).isNotNull(); + assertThat(crV2Now.getStatus()).isNotNull(); + assertThat(crV1Now.getStatus().getReconciledBy()) + .containsExactly(MultiVersionCRDTestReconciler1.class.getSimpleName()); + assertThat(crV2Now.getStatus().getReconciledBy()) + .containsExactly(MultiVersionCRDTestReconciler2.class.getSimpleName()); + }); + } + + @Test + void invalidEventsShouldStopInformerAndCallInformerStoppedHandler() { + informerStoppedHandler.reset(); + var v2res = createTestResourceV2WithLabel(); + v2res.getMetadata().getLabels().clear(); + operator.create(v2res); + var v1res = createTestResourceV1WithoutLabel(); + operator.create(v1res); + + await() + .atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofMillis(50)) + .untilAsserted( + () -> { + // v1 is the stored version so trying to create a v2 version should fail because we + // cannot + // convert a String (as defined by the spec of the v2 CRD) to an int (which is what + // the + // spec of the v1 CRD defines) + assertThat(informerStoppedHandler.getResourceCreateAsVersion()) + .isEqualTo(HasMetadata.getApiVersion(MultiVersionCRDTestCustomResource1.class)); + assertThat(informerStoppedHandler.getResourceClassName()) + .isEqualTo(MultiVersionCRDTestCustomResource1.class.getName()); + assertThat(informerStoppedHandler.getFailedResourceVersion()) + .isEqualTo(HasMetadata.getApiVersion(MultiVersionCRDTestCustomResource2.class)); + assertThat(informerStoppedHandler.getErrorMessage()) + .contains( + "Cannot deserialize value of type `int` from String \"string value\": not a" + + " valid `int` value"); + }); + assertThat(operator.get(MultiVersionCRDTestCustomResource2.class, CR_V2_NAME).getStatus()) + .isNull(); + } + + MultiVersionCRDTestCustomResource1 createTestResourceV1WithoutLabel() { + MultiVersionCRDTestCustomResource1 cr = new MultiVersionCRDTestCustomResource1(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName(CR_V1_NAME); + cr.setSpec(new MultiVersionCRDTestCustomResourceSpec1()); + cr.getSpec().setValue(1); + return cr; + } + + MultiVersionCRDTestCustomResource2 createTestResourceV2WithLabel() { + MultiVersionCRDTestCustomResource2 cr = new MultiVersionCRDTestCustomResource2(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName(CR_V2_NAME); + cr.getMetadata().setLabels(new HashMap<>()); + cr.getMetadata().getLabels().put("version", "v2"); + cr.setSpec(new MultiVersionCRDTestCustomResourceSpec2()); + cr.getSpec().setValue("string value"); + return cr; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResource1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResource1.java new file mode 100644 index 0000000000..f376cba7f0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResource1.java @@ -0,0 +1,17 @@ +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; + +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("MultiVersionCRDTestCustomResource") +@ShortNames("mvc") +public class MultiVersionCRDTestCustomResource1 + extends CustomResource< + MultiVersionCRDTestCustomResourceSpec1, MultiVersionCRDTestCustomResourceStatus1> + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResource2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResource2.java new file mode 100644 index 0000000000..87fd47064d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResource2.java @@ -0,0 +1,17 @@ +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; + +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(value = "v2", storage = false) +@Kind("MultiVersionCRDTestCustomResource") +@ShortNames("mvc") +public class MultiVersionCRDTestCustomResource2 + extends CustomResource< + MultiVersionCRDTestCustomResourceSpec2, MultiVersionCRDTestCustomResourceStatus2> + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec1.java new file mode 100644 index 0000000000..d870d4d315 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec1.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; + +public class MultiVersionCRDTestCustomResourceSpec1 { + + private int value; + + public int getValue() { + return value; + } + + public MultiVersionCRDTestCustomResourceSpec1 setValue(int value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec2.java new file mode 100644 index 0000000000..2219acca36 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec2.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; + +public class MultiVersionCRDTestCustomResourceSpec2 { + + private String value; + + public String getValue() { + return value; + } + + public MultiVersionCRDTestCustomResourceSpec2 setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus1.java new file mode 100644 index 0000000000..17c0f00bab --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus1.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; + +import java.util.ArrayList; +import java.util.List; + +public class MultiVersionCRDTestCustomResourceStatus1 { + + private int value1; + + private List reconciledBy = new ArrayList<>(); + + public int getValue1() { + return value1; + } + + public MultiVersionCRDTestCustomResourceStatus1 setValue1(int value1) { + this.value1 = value1; + return this; + } + + public List getReconciledBy() { + return reconciledBy; + } + + public MultiVersionCRDTestCustomResourceStatus1 setReconciledBy(List reconciledBy) { + this.reconciledBy = reconciledBy; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus2.java new file mode 100644 index 0000000000..5af2e55177 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus2.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; + +import java.util.ArrayList; +import java.util.List; + +public class MultiVersionCRDTestCustomResourceStatus2 { + + private int value1; + + private List reconciledBy = new ArrayList<>(); + + public int getValue1() { + return value1; + } + + public MultiVersionCRDTestCustomResourceStatus2 setValue1(int value1) { + this.value1 = value1; + return this; + } + + public List getReconciledBy() { + return reconciledBy; + } + + public MultiVersionCRDTestCustomResourceStatus2 setReconciledBy(List reconciledBy) { + this.reconciledBy = reconciledBy; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestReconciler1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestReconciler1.java new file mode 100644 index 0000000000..870ba979c4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestReconciler1.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +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; + +@ControllerConfiguration(informer = @Informer(labelSelector = "!version")) +public class MultiVersionCRDTestReconciler1 + implements Reconciler { + + private static final Logger log = LoggerFactory.getLogger(MultiVersionCRDTestReconciler1.class); + + @Override + public UpdateControl reconcile( + MultiVersionCRDTestCustomResource1 resource, + Context context) { + log.info("Reconcile MultiVersionCRDTestCustomResource1: {}", resource.getMetadata().getName()); + if (resource.getStatus() == null) { + resource.setStatus(new MultiVersionCRDTestCustomResourceStatus1()); + } + resource.getStatus().setValue1(resource.getStatus().getValue1() + 1); + if (!resource.getStatus().getReconciledBy().contains(getClass().getSimpleName())) { + resource.getStatus().getReconciledBy().add(getClass().getSimpleName()); + } + return UpdateControl.patchStatus(resource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestReconciler2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestReconciler2.java new file mode 100644 index 0000000000..a0457ccf1e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestReconciler2.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +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; + +@ControllerConfiguration(informer = @Informer(labelSelector = "version in (v2)")) +public class MultiVersionCRDTestReconciler2 + implements Reconciler { + + private static final Logger log = LoggerFactory.getLogger(MultiVersionCRDTestReconciler2.class); + + @Override + public UpdateControl reconcile( + MultiVersionCRDTestCustomResource2 resource, + Context context) { + log.info("Reconcile MultiVersionCRDTestCustomResource2: {}", resource.getMetadata().getName()); + if (resource.getStatus() == null) { + resource.setStatus(new MultiVersionCRDTestCustomResourceStatus2()); + } + resource.getStatus().setValue1(resource.getStatus().getValue1() + 1); + if (!resource.getStatus().getReconciledBy().contains(getClass().getSimpleName())) { + resource.getStatus().getReconciledBy().add(getClass().getSimpleName()); + } + return UpdateControl.patchStatus(resource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentCustomResource.java new file mode 100644 index 0000000000..2b1ddbcbec --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.nextreconciliationimminent; + +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("nri") +public class NextReconciliationImminentCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentIT.java new file mode 100644 index 0000000000..03bd6b0a7a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentIT.java @@ -0,0 +1,66 @@ +package io.javaoperatorsdk.operator.baseapi.nextreconciliationimminent; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class NextReconciliationImminentIT { + + private static final Logger log = LoggerFactory.getLogger(NextReconciliationImminentIT.class); + + public static final int WAIT_FOR_EVENT = 300; + public static final String TEST_RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new NextReconciliationImminentReconciler()) + .build(); + + @Test + void skippingStatusUpdateWithNextReconciliationImminent() throws InterruptedException { + var resource = extension.create(testResource()); + + var reconciler = extension.getReconcilerOfType(NextReconciliationImminentReconciler.class); + await().untilAsserted(() -> assertThat(reconciler.isReconciliationWaiting()).isTrue()); + Thread.sleep(WAIT_FOR_EVENT); + + resource.getMetadata().getAnnotations().put("trigger", "" + System.currentTimeMillis()); + extension.replace(resource); + Thread.sleep(WAIT_FOR_EVENT); + log.info("Made change to trigger event"); + + reconciler.allowReconciliationToProceed(); + Thread.sleep(WAIT_FOR_EVENT); + // second event arrived + await().untilAsserted(() -> assertThat(reconciler.isReconciliationWaiting()).isTrue()); + reconciler.allowReconciliationToProceed(); + + await() + .pollDelay(Duration.ofMillis(WAIT_FOR_EVENT)) + .untilAsserted( + () -> { + assertThat( + extension + .get(NextReconciliationImminentCustomResource.class, TEST_RESOURCE_NAME) + .getStatus() + .getUpdateNumber()) + .isEqualTo(1); + }); + } + + NextReconciliationImminentCustomResource testResource() { + var res = new NextReconciliationImminentCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentReconciler.java new file mode 100644 index 0000000000..7e8b5a49fd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentReconciler.java @@ -0,0 +1,59 @@ +package io.javaoperatorsdk.operator.baseapi.nextreconciliationimminent; + +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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; + +@ControllerConfiguration(generationAwareEventProcessing = false) +public class NextReconciliationImminentReconciler + implements Reconciler { + + private static final Logger log = + LoggerFactory.getLogger(NextReconciliationImminentReconciler.class); + + private final SynchronousQueue queue = new SynchronousQueue<>(); + private volatile boolean reconciliationWaiting = false; + + @Override + public UpdateControl reconcile( + NextReconciliationImminentCustomResource resource, + Context context) + throws InterruptedException { + log.info("started reconciliation"); + reconciliationWaiting = true; + // wait long enough to get manually allowed + queue.poll(120, TimeUnit.SECONDS); + log.info("Continue after wait"); + reconciliationWaiting = false; + + if (context.isNextReconciliationImminent()) { + return UpdateControl.noUpdate(); + } else { + if (resource.getStatus() == null) { + resource.setStatus(new NextReconciliationImminentStatus()); + } + resource.getStatus().setUpdateNumber(resource.getStatus().getUpdateNumber() + 1); + log.info("Patching status"); + return UpdateControl.patchStatus(resource); + } + } + + public void allowReconciliationToProceed() { + try { + queue.put(true); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public boolean isReconciliationWaiting() { + return reconciliationWaiting; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentStatus.java new file mode 100644 index 0000000000..66aed10ba3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/nextreconciliationimminent/NextReconciliationImminentStatus.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.nextreconciliationimminent; + +public class NextReconciliationImminentStatus { + + private int updateNumber; + + public int getUpdateNumber() { + return updateNumber; + } + + public void setUpdateNumber(int updateNumber) { + this.updateNumber = updateNumber; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSACustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSACustomResource.java new file mode 100644 index 0000000000..9411f43e25 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSACustomResource.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourceandstatusnossa; + +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("DoubleUpdateSample") +@ShortNames("du") +public class PatchResourceAndStatusNoSSACustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAIT.java new file mode 100644 index 0000000000..a835dd2de6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAIT.java @@ -0,0 +1,93 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourceandstatusnossa; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.TestUtils; + +import static io.javaoperatorsdk.operator.baseapi.patchresourceandstatusnossa.PatchResourceAndStatusNoSSAReconciler.TEST_ANNOTATION; +import static io.javaoperatorsdk.operator.baseapi.patchresourceandstatusnossa.PatchResourceAndStatusNoSSAReconciler.TEST_ANNOTATION_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class PatchResourceAndStatusNoSSAIT { + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withConfigurationService(o -> o.withUseSSAToPatchPrimaryResource(false)) + .withReconciler(PatchResourceAndStatusNoSSAReconciler.class) + .build(); + + @Test + void updatesSubResourceStatus() { + extension + .getReconcilerOfType(PatchResourceAndStatusNoSSAReconciler.class) + .setRemoveAnnotation(false); + PatchResourceAndStatusNoSSACustomResource resource = createTestCustomResource("1"); + extension.create(resource); + + awaitStatusUpdated(resource.getMetadata().getName()); + // wait for sure, there are no more events + TestUtils.waitXms(300); + + PatchResourceAndStatusNoSSACustomResource customResource = + extension.get( + PatchResourceAndStatusNoSSACustomResource.class, resource.getMetadata().getName()); + + assertThat(TestUtils.getNumberOfExecutions(extension)).isEqualTo(1); + assertThat(customResource.getStatus().getState()) + .isEqualTo(PatchResourceAndStatusNoSSAStatus.State.SUCCESS); + assertThat(customResource.getMetadata().getAnnotations().get(TEST_ANNOTATION)).isNotNull(); + } + + @Test + void removeAnnotationCorrectlyUpdatesStatus() { + extension + .getReconcilerOfType(PatchResourceAndStatusNoSSAReconciler.class) + .setRemoveAnnotation(true); + PatchResourceAndStatusNoSSACustomResource resource = createTestCustomResource("1"); + resource.getMetadata().setAnnotations(Map.of(TEST_ANNOTATION, TEST_ANNOTATION_VALUE)); + extension.create(resource); + + awaitStatusUpdated(resource.getMetadata().getName()); + // wait for sure, there are no more events + TestUtils.waitXms(300); + + PatchResourceAndStatusNoSSACustomResource customResource = + extension.get( + PatchResourceAndStatusNoSSACustomResource.class, resource.getMetadata().getName()); + + assertThat(TestUtils.getNumberOfExecutions(extension)).isEqualTo(1); + assertThat(customResource.getStatus().getState()) + .isEqualTo(PatchResourceAndStatusNoSSAStatus.State.SUCCESS); + assertThat(customResource.getMetadata().getAnnotations().get(TEST_ANNOTATION)).isNull(); + } + + void awaitStatusUpdated(String name) { + await("cr status updated") + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + PatchResourceAndStatusNoSSACustomResource cr = + extension.get(PatchResourceAndStatusNoSSACustomResource.class, name); + assertThat(cr).isNotNull(); + assertThat(cr.getStatus()).isNotNull(); + assertThat(cr.getStatus().getState()) + .isEqualTo(PatchResourceAndStatusNoSSAStatus.State.SUCCESS); + }); + } + + public PatchResourceAndStatusNoSSACustomResource createTestCustomResource(String id) { + PatchResourceAndStatusNoSSACustomResource resource = + new PatchResourceAndStatusNoSSACustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName("doubleupdateresource-" + id).build()); + resource.setSpec(new PatchResourceAndStatusNoSSASpec()); + resource.getSpec().setValue(id); + return resource; + } +} 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 new file mode 100644 index 0000000000..2d3a282b01 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAReconciler.java @@ -0,0 +1,62 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourceandstatusnossa; + +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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; + +@ControllerConfiguration +public class PatchResourceAndStatusNoSSAReconciler + implements Reconciler, TestExecutionInfoProvider { + + private static final Logger log = + LoggerFactory.getLogger(PatchResourceAndStatusNoSSAReconciler.class); + public static final String TEST_ANNOTATION = "TestAnnotation"; + public static final String TEST_ANNOTATION_VALUE = "TestAnnotationValue"; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + private volatile boolean removeAnnotation = false; + + @Override + public UpdateControl reconcile( + PatchResourceAndStatusNoSSACustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + + log.info("Value: " + resource.getSpec().getValue()); + + if (removeAnnotation) { + resource.getMetadata().getAnnotations().remove(TEST_ANNOTATION); + } else { + resource.getMetadata().setAnnotations(new HashMap<>()); + resource.getMetadata().getAnnotations().put(TEST_ANNOTATION, TEST_ANNOTATION_VALUE); + } + ensureStatusExists(resource); + resource.getStatus().setState(PatchResourceAndStatusNoSSAStatus.State.SUCCESS); + + return UpdateControl.patchResourceAndStatus(resource); + } + + private void ensureStatusExists(PatchResourceAndStatusNoSSACustomResource resource) { + PatchResourceAndStatusNoSSAStatus status = resource.getStatus(); + if (status == null) { + status = new PatchResourceAndStatusNoSSAStatus(); + resource.setStatus(status); + } + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + public void setRemoveAnnotation(boolean removeAnnotation) { + this.removeAnnotation = removeAnnotation; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSASpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSASpec.java new file mode 100644 index 0000000000..aa8aeca39c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSASpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourceandstatusnossa; + +public class PatchResourceAndStatusNoSSASpec { + + private String value; + + public String getValue() { + return value; + } + + public PatchResourceAndStatusNoSSASpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAStatus.java new file mode 100644 index 0000000000..1f1bb5db9b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourceandstatusnossa/PatchResourceAndStatusNoSSAStatus.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourceandstatusnossa; + +public class PatchResourceAndStatusNoSSAStatus { + + private State state; + + public State getState() { + return state; + } + + public PatchResourceAndStatusNoSSAStatus setState(State state) { + this.state = state; + return this; + } + + public enum State { + SUCCESS, + ERROR + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceAndStatusWithSSAIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceAndStatusWithSSAIT.java new file mode 100644 index 0000000000..f395706092 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceAndStatusWithSSAIT.java @@ -0,0 +1,11 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourcewithssa; + +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; + +public class PatchResourceAndStatusWithSSAIT extends PatchWithSSAITBase { + + @Override + protected Reconciler reconciler() { + return new PatchResourceAndStatusWithSSAReconciler(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceAndStatusWithSSAReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceAndStatusWithSSAReconciler.java new file mode 100644 index 0000000000..f3449c4d05 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceAndStatusWithSSAReconciler.java @@ -0,0 +1,39 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourcewithssa; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.*; + +@ControllerConfiguration +public class PatchResourceAndStatusWithSSAReconciler + implements Reconciler, + Cleaner { + + public static final String ADDED_VALUE = "Added Value"; + + @Override + public UpdateControl reconcile( + PatchResourceWithSSACustomResource resource, + Context context) { + + var res = new PatchResourceWithSSACustomResource(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .build()); + + res.setSpec(new PatchResourceWithSSASpec()); + res.getSpec().setControllerManagedValue(ADDED_VALUE); + res.setStatus(new PatchResourceWithSSAStatus()); + res.getStatus().setSuccessfullyReconciled(true); + + return UpdateControl.patchResourceAndStatus(res); + } + + @Override + public DeleteControl cleanup( + PatchResourceWithSSACustomResource resource, + Context context) { + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSACustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSACustomResource.java new file mode 100644 index 0000000000..8203f0a85e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSACustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourcewithssa; + +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("prs") +public class PatchResourceWithSSACustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAIT.java new file mode 100644 index 0000000000..d512665d06 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAIT.java @@ -0,0 +1,11 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourcewithssa; + +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; + +public class PatchResourceWithSSAIT extends PatchWithSSAITBase { + + @Override + protected Reconciler reconciler() { + return new PatchResourceWithSSAReconciler(); + } +} 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 new file mode 100644 index 0000000000..7bc6c2b42a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAReconciler.java @@ -0,0 +1,43 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourcewithssa; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.*; + +@ControllerConfiguration +public class PatchResourceWithSSAReconciler + implements Reconciler, + Cleaner { + + public static final String ADDED_VALUE = "Added Value"; + + @Override + public UpdateControl reconcile( + PatchResourceWithSSACustomResource resource, + Context context) { + + var res = new PatchResourceWithSSACustomResource(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .build()); + + // first update the spec with missing value, then status in next reconciliation + if (resource.getSpec().getControllerManagedValue() == null) { + res.setSpec(new PatchResourceWithSSASpec()); + res.getSpec().setControllerManagedValue(ADDED_VALUE); + return UpdateControl.patchResource(res); + } else { + res.setStatus(new PatchResourceWithSSAStatus()); + res.getStatus().setSuccessfullyReconciled(true); + return UpdateControl.patchStatus(res); + } + } + + @Override + public DeleteControl cleanup( + PatchResourceWithSSACustomResource resource, + Context context) { + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSASpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSASpec.java new file mode 100644 index 0000000000..253625af56 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSASpec.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourcewithssa; + +public class PatchResourceWithSSASpec { + + private String initValue; + private String controllerManagedValue; + + public String getInitValue() { + return initValue; + } + + public void setInitValue(String initValue) { + this.initValue = initValue; + } + + public String getControllerManagedValue() { + return controllerManagedValue; + } + + public void setControllerManagedValue(String controllerManagedValue) { + this.controllerManagedValue = controllerManagedValue; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAStatus.java new file mode 100644 index 0000000000..f3d5f7806c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchResourceWithSSAStatus.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourcewithssa; + +public class PatchResourceWithSSAStatus { + + private boolean successfullyReconciled; + + public boolean isSuccessfullyReconciled() { + return successfullyReconciled; + } + + public void setSuccessfullyReconciled(boolean successfullyReconciled) { + this.successfullyReconciled = successfullyReconciled; + } +} 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 new file mode 100644 index 0000000000..4fdb35d800 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/patchresourcewithssa/PatchWithSSAITBase.java @@ -0,0 +1,60 @@ +package io.javaoperatorsdk.operator.baseapi.patchresourcewithssa; + +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.reconciler.Reconciler; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public abstract class PatchWithSSAITBase { + + public static final String RESOURCE_NAME = "test1"; + public static final String INIT_VALUE = "init value"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(reconciler()).build(); + + @Test + void reconcilerPatchesResourceWithSSA() { + extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var actualResource = + extension.get(PatchResourceWithSSACustomResource.class, RESOURCE_NAME); + + assertThat(actualResource.getSpec().getInitValue()).isEqualTo(INIT_VALUE); + assertThat(actualResource.getSpec().getControllerManagedValue()) + .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().isSuccessfullyReconciled()).isTrue(); + // one for resource, one for subresource + assertThat( + actualResource.getMetadata().getManagedFields().stream() + .filter( + mf -> + mf.getManager() + .equals( + reconciler().getClass().getSimpleName().toLowerCase())) + .toList()) + .hasSize(2); + }); + } + + protected abstract Reconciler reconciler(); + + PatchResourceWithSSACustomResource testResource() { + var res = new PatchResourceWithSSACustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + res.setSpec(new PatchResourceWithSSASpec()); + res.getSpec().setInitValue(INIT_VALUE); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/perresourceeventsource/PerResourceEventSourceCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/perresourceeventsource/PerResourceEventSourceCustomResource.java new file mode 100644 index 0000000000..3efae6a309 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/perresourceeventsource/PerResourceEventSourceCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.perresourceeventsource; + +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("pres") +public class PerResourceEventSourceCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/perresourceeventsource/PerResourcePollingEventSourceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/perresourceeventsource/PerResourcePollingEventSourceIT.java new file mode 100644 index 0000000000..20f89e0d39 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/perresourceeventsource/PerResourcePollingEventSourceIT.java @@ -0,0 +1,49 @@ +package io.javaoperatorsdk.operator.baseapi.perresourceeventsource; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class PerResourcePollingEventSourceIT { + + public static final String NAME_1 = "name1"; + public static final String NAME_2 = "name2"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new PerResourcePollingEventSourceTestReconciler()) + .build(); + + /** + * This is kinda some test to verify that the implementation of PerResourcePollingEventSource + * works with the underling mechanisms in event source manager and other parts of the system. + */ + @Test + void fetchedAndReconciledMultipleTimes() { + operator.create(resource(NAME_1)); + operator.create(resource(NAME_2)); + + var reconciler = + operator.getReconcilerOfType(PerResourcePollingEventSourceTestReconciler.class); + await() + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions(NAME_1)).isGreaterThan(2); + assertThat(reconciler.getNumberOfFetchExecution(NAME_1)).isGreaterThan(2); + assertThat(reconciler.getNumberOfExecutions(NAME_2)).isGreaterThan(2); + assertThat(reconciler.getNumberOfFetchExecution(NAME_2)).isGreaterThan(2); + }); + } + + private PerResourceEventSourceCustomResource resource(String name) { + var res = new PerResourceEventSourceCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(name).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/perresourceeventsource/PerResourcePollingEventSourceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/perresourceeventsource/PerResourcePollingEventSourceTestReconciler.java new file mode 100644 index 0000000000..b34c6ef863 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/perresourceeventsource/PerResourcePollingEventSourceTestReconciler.java @@ -0,0 +1,64 @@ +package io.javaoperatorsdk.operator.baseapi.perresourceeventsource; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingConfigurationBuilder; +import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingEventSource; + +@ControllerConfiguration +public class PerResourcePollingEventSourceTestReconciler + implements Reconciler { + + public static final int POLL_PERIOD = 100; + private final Map numberOfExecutions = new ConcurrentHashMap<>(); + private final Map numberOfFetchExecutions = new ConcurrentHashMap<>(); + + @Override + public UpdateControl reconcile( + PerResourceEventSourceCustomResource resource, + Context context) + throws Exception { + numberOfExecutions.putIfAbsent(resource.getMetadata().getName(), 0); + numberOfExecutions.compute(resource.getMetadata().getName(), (s, v) -> v + 1); + return UpdateControl.noUpdate(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + PerResourcePollingEventSource eventSource = + new PerResourcePollingEventSource<>( + String.class, + context, + new PerResourcePollingConfigurationBuilder<>( + (PerResourceEventSourceCustomResource resource) -> { + numberOfFetchExecutions.putIfAbsent(resource.getMetadata().getName(), 0); + numberOfFetchExecutions.compute( + resource.getMetadata().getName(), (s, v) -> v + 1); + return Set.of(UUID.randomUUID().toString()); + }, + Duration.ofMillis(POLL_PERIOD)) + .build()); + return List.of(eventSource); + } + + public int getNumberOfExecutions(String name) { + var num = numberOfExecutions.get(name); + return num == null ? 0 : num; + } + + public int getNumberOfFetchExecution(String name) { + return numberOfFetchExecutions.get(name); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/AbstractPrimaryIndexerTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/AbstractPrimaryIndexerTestReconciler.java new file mode 100644 index 0000000000..9294a597dd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/AbstractPrimaryIndexerTestReconciler.java @@ -0,0 +1,39 @@ +package io.javaoperatorsdk.operator.baseapi.primaryindexer; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +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; + +@ControllerConfiguration +public class AbstractPrimaryIndexerTestReconciler + implements Reconciler { + + public static final String CONFIG_MAP_NAME = "common-config-map"; + + private final Map numberOfExecutions = new ConcurrentHashMap<>(); + + protected static final String CONFIG_MAP_RELATION_INDEXER = "cm-indexer"; + + protected static final Function> indexer = + resource -> List.of(resource.getSpec().getConfigMapName()); + + @Override + public UpdateControl reconcile( + PrimaryIndexerTestCustomResource resource, + Context context) { + numberOfExecutions.computeIfAbsent(resource.getMetadata().getName(), r -> new AtomicInteger(0)); + numberOfExecutions.get(resource.getMetadata().getName()).incrementAndGet(); + return UpdateControl.noUpdate(); + } + + public Map getNumberOfExecutions() { + return numberOfExecutions; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerIT.java new file mode 100644 index 0000000000..9063ef0fcb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerIT.java @@ -0,0 +1,70 @@ +package io.javaoperatorsdk.operator.baseapi.primaryindexer; + +import java.time.Duration; + +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.ObjectMeta; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.primaryindexer.AbstractPrimaryIndexerTestReconciler.CONFIG_MAP_NAME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class PrimaryIndexerIT { + + public static final String RESOURCE_NAME1 = "test1"; + public static final String RESOURCE_NAME2 = "test2"; + + @RegisterExtension LocallyRunOperatorExtension operator = buildOperator(); + + protected LocallyRunOperatorExtension buildOperator() { + return LocallyRunOperatorExtension.builder() + .withReconciler(new PrimaryIndexerTestReconciler()) + .build(); + } + + @Test + void changesToSecondaryResourcesCorrectlyTriggerReconciler() { + var reconciler = (AbstractPrimaryIndexerTestReconciler) operator.getFirstReconciler(); + operator.create(createTestResource(RESOURCE_NAME1)); + operator.create(createTestResource(RESOURCE_NAME2)); + + await() + .pollDelay(Duration.ofMillis(500)) + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions().get(RESOURCE_NAME1).get()).isEqualTo(1); + assertThat(reconciler.getNumberOfExecutions().get(RESOURCE_NAME2).get()).isEqualTo(1); + }); + + operator.create(configMap()); + + await() + .pollDelay(Duration.ofMillis(500)) + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions().get(RESOURCE_NAME1).get()).isEqualTo(2); + assertThat(reconciler.getNumberOfExecutions().get(RESOURCE_NAME2).get()).isEqualTo(2); + }); + } + + private ConfigMap configMap() { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new ObjectMeta()); + configMap.getMetadata().setName(CONFIG_MAP_NAME); + + return configMap; + } + + private PrimaryIndexerTestCustomResource createTestResource(String name) { + PrimaryIndexerTestCustomResource cr = new PrimaryIndexerTestCustomResource(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName(name); + cr.setSpec(new PrimaryIndexerTestCustomResourceSpec()); + cr.getSpec().setConfigMapName(CONFIG_MAP_NAME); + return cr; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestCustomResource.java new file mode 100644 index 0000000000..2b338c3263 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestCustomResource.java @@ -0,0 +1,17 @@ +package io.javaoperatorsdk.operator.baseapi.primaryindexer; + +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("PrimaryIndexerTestCustomResource") +@ShortNames("pi") +public class PrimaryIndexerTestCustomResource + extends CustomResource< + PrimaryIndexerTestCustomResourceSpec, PrimaryIndexerTestCustomResourceStatus> + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestCustomResourceSpec.java new file mode 100644 index 0000000000..65cbf68d80 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestCustomResourceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.primaryindexer; + +public class PrimaryIndexerTestCustomResourceSpec { + + private String configMapName; + + public String getConfigMapName() { + return configMapName; + } + + public PrimaryIndexerTestCustomResourceSpec setConfigMapName(String configMapName) { + this.configMapName = configMapName; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestCustomResourceStatus.java new file mode 100644 index 0000000000..313e2b6a68 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestCustomResourceStatus.java @@ -0,0 +1,3 @@ +package io.javaoperatorsdk.operator.baseapi.primaryindexer; + +public class PrimaryIndexerTestCustomResourceStatus {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestReconciler.java new file mode 100644 index 0000000000..d7ece6fe3c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primaryindexer/PrimaryIndexerTestReconciler.java @@ -0,0 +1,39 @@ +package io.javaoperatorsdk.operator.baseapi.primaryindexer; + +import java.util.List; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +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 PrimaryIndexerTestReconciler extends AbstractPrimaryIndexerTestReconciler { + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + context.getPrimaryCache().addIndexer(CONFIG_MAP_RELATION_INDEXER, indexer); + + var informerConfiguration = + InformerEventSourceConfiguration.from( + ConfigMap.class, PrimaryIndexerTestCustomResource.class) + .withSecondaryToPrimaryMapper( + (ConfigMap secondaryResource) -> + context + .getPrimaryCache() + .byIndex( + CONFIG_MAP_RELATION_INDEXER, secondaryResource.getMetadata().getName()) + .stream() + .map(ResourceID::fromResource) + .collect(Collectors.toSet())) + .build(); + + return List.of(new InformerEventSource<>(informerConfiguration, context)); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/Cluster.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/Cluster.java new file mode 100644 index 0000000000..1d154cd6a8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/Cluster.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.baseapi.primarytosecondary; + +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("clu") +public class Cluster extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/ClusterSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/ClusterSpec.java new file mode 100644 index 0000000000..b948bea6b4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/ClusterSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.primarytosecondary; + +public class ClusterSpec { + + private String clusterValue; + + public String getClusterValue() { + return clusterValue; + } + + public void setClusterValue(String clusterValue) { + this.clusterValue = clusterValue; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/Job.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/Job.java new file mode 100644 index 0000000000..bec3598e73 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/Job.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.baseapi.primarytosecondary; + +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("cjo") +public class Job extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobReconciler.java new file mode 100644 index 0000000000..6c51a06b1c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobReconciler.java @@ -0,0 +1,138 @@ +package io.javaoperatorsdk.operator.baseapi.primarytosecondary; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.PrimaryToSecondaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +/** + * This reconciler used in integration tests to show the cases when PrimaryToSecondaryMapper is + * needed, and to show the use cases when some mechanisms would not work without that. It's not + * intended to be a reusable code as it is, rather serves for deeper understanding of the problem. + */ +@ControllerConfiguration +public class JobReconciler implements Reconciler { + + private static final String JOB_CLUSTER_INDEX = "job-cluster-index"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + private final boolean addPrimaryToSecondaryMapper; + private boolean getResourceDirectlyFromCache = false; + private volatile boolean errorOccurred; + + public JobReconciler() { + this(true); + } + + public JobReconciler(boolean addPrimaryToSecondaryMapper) { + this.addPrimaryToSecondaryMapper = addPrimaryToSecondaryMapper; + } + + @Override + public UpdateControl reconcile(Job resource, Context context) { + Cluster cluster; + if (!getResourceDirectlyFromCache) { + // this is only possible when there is primary to secondary mapper + cluster = + context + .getSecondaryResource(Cluster.class) + .orElseThrow(() -> new IllegalStateException("Secondary resource should be present")); + } else { + // reading the resource from cache as alternative, works without primary to secondary mapper + var informerEventSource = + (InformerEventSource) + context.eventSourceRetriever().getEventSourceFor(Cluster.class); + cluster = + informerEventSource + .get( + new ResourceID( + resource.getSpec().getClusterName(), resource.getMetadata().getNamespace())) + .orElseThrow( + () -> new IllegalStateException("Secondary resource cannot be read from cache")); + } + if (resource.getStatus() == null) { + resource.setStatus(new JobStatus()); + } + numberOfExecutions.addAndGet(1); + // copy a value to job status, to we can test triggering + if (!cluster.getSpec().getClusterValue().equals(resource.getStatus().getValueFromCluster())) { + resource.getStatus().setValueFromCluster(cluster.getSpec().getClusterValue()); + return UpdateControl.patchStatus(resource); + } else { + return UpdateControl.noUpdate(); + } + } + + @Override + public List> prepareEventSources(EventSourceContext context) { + context + .getPrimaryCache() + .addIndexer( + JOB_CLUSTER_INDEX, + (job -> + List.of( + indexKey(job.getSpec().getClusterName(), job.getMetadata().getNamespace())))); + + InformerEventSourceConfiguration.Builder informerConfiguration = + InformerEventSourceConfiguration.from(Cluster.class, Job.class) + .withSecondaryToPrimaryMapper( + cluster -> + context + .getPrimaryCache() + .byIndex( + JOB_CLUSTER_INDEX, + indexKey( + cluster.getMetadata().getName(), + cluster.getMetadata().getNamespace())) + .stream() + .map(ResourceID::fromResource) + .collect(Collectors.toSet())) + .withNamespacesInheritedFromController(); + + if (addPrimaryToSecondaryMapper) { + informerConfiguration = + informerConfiguration.withPrimaryToSecondaryMapper( + (PrimaryToSecondaryMapper) + primary -> + Set.of( + new ResourceID( + primary.getSpec().getClusterName(), + primary.getMetadata().getNamespace()))); + } + + return List.of(new InformerEventSource<>(informerConfiguration.build(), context)); + } + + private String indexKey(String clusterName, String namespace) { + return clusterName + "#" + namespace; + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + Job resource, Context context, Exception e) { + errorOccurred = true; + return ErrorStatusUpdateControl.noStatusUpdate(); + } + + public boolean isErrorOccurred() { + return errorOccurred; + } + + @SuppressWarnings("UnusedReturnValue") + public JobReconciler setGetResourceDirectlyFromCache(boolean getResourceDirectlyFromCache) { + this.getResourceDirectlyFromCache = getResourceDirectlyFromCache; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobSpec.java new file mode 100644 index 0000000000..c450129627 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.primarytosecondary; + +public class JobSpec { + + private String clusterName; + + public String getClusterName() { + return clusterName; + } + + public JobSpec setClusterName(String clusterName) { + this.clusterName = clusterName; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobStatus.java new file mode 100644 index 0000000000..d59634b295 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/JobStatus.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.primarytosecondary; + +public class JobStatus { + + private String valueFromCluster; + + public String getValueFromCluster() { + return valueFromCluster; + } + + public void setValueFromCluster(String valueFromCluster) { + this.valueFromCluster = valueFromCluster; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/PrimaryToSecondaryIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/PrimaryToSecondaryIT.java new file mode 100644 index 0000000000..d82c24a55f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/PrimaryToSecondaryIT.java @@ -0,0 +1,75 @@ +package io.javaoperatorsdk.operator.baseapi.primarytosecondary; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class PrimaryToSecondaryIT { + + public static final String CLUSTER_NAME = "cluster1"; + public static final int MIN_DELAY = 150; + + public static final String CLUSTER_VALUE = "clusterValue"; + public static final String JOB_1 = "job1"; + public static final String CHANGED_VALUE = "CHANGED_VALUE"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withAdditionalCustomResourceDefinition(Cluster.class) + .withReconciler(new JobReconciler()) + .build(); + + @Test + void readsSecondaryInManyToOneCases() throws InterruptedException { + var cluster = extension.create(cluster()); + Thread.sleep(MIN_DELAY); + extension.create(job()); + + await() + .pollDelay(Duration.ofMillis(300)) + .untilAsserted( + () -> { + assertThat(extension.getReconcilerOfType(JobReconciler.class).getNumberOfExecutions()) + .isEqualTo(1); + var job = extension.get(Job.class, JOB_1); + assertThat(job.getStatus()).isNotNull(); + assertThat(job.getStatus().getValueFromCluster()).isEqualTo(CLUSTER_VALUE); + }); + + cluster.getSpec().setClusterValue(CHANGED_VALUE); + extension.replace(cluster); + + // cluster change triggers job reconciliations + await() + .pollDelay(Duration.ofMillis(300)) + .untilAsserted( + () -> { + var job = extension.get(Job.class, JOB_1); + assertThat(job.getStatus().getValueFromCluster()).isEqualTo(CHANGED_VALUE); + }); + } + + public static Job job() { + var job = new Job(); + job.setMetadata(new ObjectMetaBuilder().withName(JOB_1).build()); + job.setSpec(new JobSpec()); + job.getSpec().setClusterName(CLUSTER_NAME); + return job; + } + + public static Cluster cluster() { + Cluster cluster = new Cluster(); + cluster.setMetadata(new ObjectMetaBuilder().withName(CLUSTER_NAME).build()); + cluster.setSpec(new ClusterSpec()); + cluster.getSpec().setClusterValue(CLUSTER_VALUE); + return cluster; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/PrimaryToSecondaryMissingIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/PrimaryToSecondaryMissingIT.java new file mode 100644 index 0000000000..84e6910b35 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/primarytosecondary/PrimaryToSecondaryMissingIT.java @@ -0,0 +1,56 @@ +package io.javaoperatorsdk.operator.baseapi.primarytosecondary; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.primarytosecondary.PrimaryToSecondaryIT.cluster; +import static io.javaoperatorsdk.operator.baseapi.primarytosecondary.PrimaryToSecondaryIT.job; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * The intention with this IT is to show the use cases why the PrimaryToSecondary Mapper is needed, + * and the situation when it is not working. + */ +class PrimaryToSecondaryMissingIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withAdditionalCustomResourceDefinition(Cluster.class) + .withReconciler(new JobReconciler(false)) + .build(); + + @Test + void missingPrimaryToSecondaryCausesIssueAccessingSecondary() throws InterruptedException { + var reconciler = operator.getReconcilerOfType(JobReconciler.class); + operator.create(cluster()); + Thread.sleep(300); + operator.create(job()); + + await() + .untilAsserted( + () -> { + assertThat(reconciler.isErrorOccurred()).isTrue(); + assertThat(reconciler.getNumberOfExecutions()).isZero(); + }); + } + + @Test + void accessingDirectlyTheCacheWorksWithoutPToSMapper() throws InterruptedException { + var reconciler = operator.getReconcilerOfType(JobReconciler.class); + reconciler.setGetResourceDirectlyFromCache(true); + operator.create(cluster()); + Thread.sleep(300); + operator.create(job()); + + await() + .untilAsserted( + () -> { + assertThat(reconciler.isErrorOccurred()).isFalse(); + assertThat(reconciler.getNumberOfExecutions()).isPositive(); + }); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitCustomResource.java new file mode 100644 index 0000000000..1ecd97f05e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.ratelimit; + +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("rlc") +public class RateLimitCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitCustomResourceSpec.java new file mode 100644 index 0000000000..ef9f747c20 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitCustomResourceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.ratelimit; + +public class RateLimitCustomResourceSpec { + + private int number; + + public int getNumber() { + return number; + } + + public RateLimitCustomResourceSpec setNumber(int number) { + this.number = number; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitCustomResourceStatus.java new file mode 100644 index 0000000000..e7b24ae8e0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitCustomResourceStatus.java @@ -0,0 +1,3 @@ +package io.javaoperatorsdk.operator.baseapi.ratelimit; + +public class RateLimitCustomResourceStatus {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitIT.java new file mode 100644 index 0000000000..8b04fd0095 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitIT.java @@ -0,0 +1,66 @@ +package io.javaoperatorsdk.operator.baseapi.ratelimit; + +import java.time.Duration; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.ratelimit.RateLimitReconciler.REFRESH_PERIOD; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class RateLimitIT { + + private static final Logger log = LoggerFactory.getLogger(RateLimitIT.class); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(new RateLimitReconciler()).build(); + + @Test + void rateLimitsExecution() { + var res = operator.create(createResource()); + IntStream.rangeClosed(1, 5) + .forEach( + i -> { + log.debug("replacing resource version: {}", i); + var resource = createResource(); + resource.getSpec().setNumber(i); + operator.replace(resource); + }); + await() + .pollInterval(Duration.ofMillis(100)) + .pollDelay(Duration.ofMillis(REFRESH_PERIOD / 2)) + .untilAsserted( + () -> + assertThat( + operator + .getReconcilerOfType(RateLimitReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(1)); + + await() + .pollDelay(Duration.ofMillis(REFRESH_PERIOD)) + .untilAsserted( + () -> + assertThat( + operator + .getReconcilerOfType(RateLimitReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(2)); + } + + public RateLimitCustomResource createResource() { + RateLimitCustomResource res = new RateLimitCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName("test").build()); + res.setSpec(new RateLimitCustomResourceSpec()); + res.getSpec().setNumber(0); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitReconciler.java new file mode 100644 index 0000000000..d16fcb837b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ratelimit/RateLimitReconciler.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.baseapi.ratelimit; + +import java.util.concurrent.TimeUnit; +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.processing.event.rate.RateLimited; + +@RateLimited( + maxReconciliations = 1, + within = RateLimitReconciler.REFRESH_PERIOD, + unit = TimeUnit.MILLISECONDS) +@ControllerConfiguration +public class RateLimitReconciler implements Reconciler { + + public static final int REFRESH_PERIOD = 3000; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + RateLimitCustomResource resource, Context context) { + + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} 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 new file mode 100644 index 0000000000..410d34a390 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIT.java @@ -0,0 +1,63 @@ +package io.javaoperatorsdk.operator.baseapi.retry; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; +import io.javaoperatorsdk.operator.support.TestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class RetryIT { + public static final int RETRY_INTERVAL = 150; + public static final int MAX_RETRY_ATTEMPTS = 5; + + public static final int NUMBER_FAILED_EXECUTIONS = 3; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler( + new RetryTestCustomReconciler(NUMBER_FAILED_EXECUTIONS), + new GenericRetry() + .setInitialInterval(RETRY_INTERVAL) + .withLinearRetry() + .setMaxAttempts(MAX_RETRY_ATTEMPTS)) + .build(); + + @Test + void retryFailedExecution() { + RetryTestCustomResource resource = createTestCustomResource("1"); + + operator.create(resource); + + await("cr status updated") + .pollDelay(RETRY_INTERVAL * (NUMBER_FAILED_EXECUTIONS + 2), TimeUnit.MILLISECONDS) + .pollInterval(RETRY_INTERVAL, TimeUnit.MILLISECONDS) + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + assertThat(TestUtils.getNumberOfExecutions(operator)) + .isEqualTo(NUMBER_FAILED_EXECUTIONS + 1); + + RetryTestCustomResource finalResource = + operator.get(RetryTestCustomResource.class, resource.getMetadata().getName()); + assertThat(finalResource.getStatus().getState()) + .isEqualTo(RetryTestCustomResourceStatus.State.SUCCESS); + }); + } + + public static RetryTestCustomResource createTestCustomResource(String id) { + RetryTestCustomResource resource = new RetryTestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName("retrysource-" + id).build()); + resource.setKind("retrysample"); + resource.setSpec(new RetryTestCustomResourceSpec()); + resource.getSpec().setValue(id); + return resource; + } +} 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 new file mode 100644 index 0000000000..f57a70201f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryMaxAttemptIT.java @@ -0,0 +1,40 @@ +package io.javaoperatorsdk.operator.baseapi.retry; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; + +import static io.javaoperatorsdk.operator.baseapi.retry.RetryIT.createTestCustomResource; +import static org.assertj.core.api.Assertions.assertThat; + +class RetryMaxAttemptIT { + + public static final int MAX_RETRY_ATTEMPTS = 3; + public static final int RETRY_INTERVAL = 100; + public static final int ALL_EXECUTION_TO_FAIL = 99; + + RetryTestCustomReconciler reconciler = new RetryTestCustomReconciler(ALL_EXECUTION_TO_FAIL); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler( + reconciler, + new GenericRetry() + .setInitialInterval(RETRY_INTERVAL) + .withLinearRetry() + .setMaxAttempts(MAX_RETRY_ATTEMPTS)) + .build(); + + @Test + void retryFailedExecution() throws InterruptedException { + RetryTestCustomResource resource = createTestCustomResource("max-retry"); + + operator.create(resource); + + Thread.sleep((MAX_RETRY_ATTEMPTS + 2) * RETRY_INTERVAL); + assertThat(reconciler.getNumberOfExecutions()).isEqualTo(4); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomReconciler.java new file mode 100644 index 0000000000..af156736f4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomReconciler.java @@ -0,0 +1,58 @@ +package io.javaoperatorsdk.operator.baseapi.retry; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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; + +@ControllerConfiguration +public class RetryTestCustomReconciler + implements Reconciler, TestExecutionInfoProvider { + + private static final Logger log = LoggerFactory.getLogger(RetryTestCustomReconciler.class); + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + private final AtomicInteger numberOfExecutionFails; + + public RetryTestCustomReconciler(int numberOfExecutionFails) { + this.numberOfExecutionFails = new AtomicInteger(numberOfExecutionFails); + } + + @Override + public UpdateControl reconcile( + RetryTestCustomResource resource, Context context) { + numberOfExecutions.addAndGet(1); + + log.info("Value: " + resource.getSpec().getValue()); + + if (numberOfExecutions.get() < numberOfExecutionFails.get() + 1) { + throw new RuntimeException("Testing Retry"); + } + if (context.getRetryInfo().isEmpty() || context.getRetryInfo().get().isLastAttempt()) { + throw new IllegalStateException("Not expected retry info: " + context.getRetryInfo()); + } + + ensureStatusExists(resource); + resource.getStatus().setState(RetryTestCustomResourceStatus.State.SUCCESS); + + return UpdateControl.patchStatus(resource); + } + + private void ensureStatusExists(RetryTestCustomResource resource) { + RetryTestCustomResourceStatus status = resource.getStatus(); + if (status == null) { + status = new RetryTestCustomResourceStatus(); + resource.setStatus(status); + } + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResource.java new file mode 100644 index 0000000000..492d8b77bb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResource.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.baseapi.retry; + +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("retrysample") +@ShortNames("rs") +public class RetryTestCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceSpec.java new file mode 100644 index 0000000000..169233e7d4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.retry; + +public class RetryTestCustomResourceSpec { + + private String value; + + public String getValue() { + return value; + } + + public RetryTestCustomResourceSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceStatus.java new file mode 100644 index 0000000000..363195c34f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceStatus.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.baseapi.retry; + +public class RetryTestCustomResourceStatus { + + private State state; + + public State getState() { + return state; + } + + public RetryTestCustomResourceStatus setState(State state) { + this.state = state; + return this; + } + + public enum State { + SUCCESS, + ERROR + } +} 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 new file mode 100644 index 0000000000..cbd8de4459 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java @@ -0,0 +1,101 @@ +package io.javaoperatorsdk.operator.baseapi.simple; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ConfigMap; +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; + +class ReconcilerExecutorIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(new TestReconciler(true)).build(); + + @Test + void configMapGetsCreatedForTestCustomResource() { + operator.getReconcilerOfType(TestReconciler.class).setUpdateStatus(true); + + TestCustomResource resource = TestUtils.testCustomResource(); + operator.create(resource); + + awaitResourcesCreatedOrUpdated(); + awaitStatusUpdated(); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + } + + @Test + void patchesStatusForTestCustomResource() { + operator.getReconcilerOfType(TestReconciler.class).setUpdateStatus(true); + + TestCustomResource resource = TestUtils.testCustomResource(); + operator.create(resource); + + awaitStatusUpdated(); + } + + @Test + void eventIsSkippedChangedOnMetadataOnlyUpdate() { + operator.getReconcilerOfType(TestReconciler.class).setUpdateStatus(false); + + TestCustomResource resource = TestUtils.testCustomResource(); + operator.create(resource); + + awaitResourcesCreatedOrUpdated(); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); + } + + @Test + void cleanupExecuted() { + operator.getReconcilerOfType(TestReconciler.class).setUpdateStatus(true); + + TestCustomResource resource = TestUtils.testCustomResource(); + resource = operator.create(resource); + + awaitResourcesCreatedOrUpdated(); + awaitStatusUpdated(); + operator.delete(resource); + + await() + .atMost(Duration.ofSeconds(1)) + .until( + () -> + ((TestReconciler) operator.getFirstReconciler()).getNumberOfCleanupExecutions() + == 1); + } + + void awaitResourcesCreatedOrUpdated() { + await("config map created") + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + ConfigMap configMap = operator.get(ConfigMap.class, "test-config-map"); + assertThat(configMap).isNotNull(); + assertThat(configMap.getData().get("test-key")).isEqualTo("test-value"); + }); + } + + void awaitStatusUpdated() { + awaitStatusUpdated(5); + } + + void awaitStatusUpdated(int timeout) { + await("cr status updated") + .atMost(timeout, TimeUnit.SECONDS) + .untilAsserted( + () -> { + TestCustomResource cr = + operator.get(TestCustomResource.class, TestUtils.TEST_CUSTOM_RESOURCE_NAME); + assertThat(cr).isNotNull(); + assertThat(cr.getStatus()).isNotNull(); + assertThat(cr.getStatus().getConfigMapStatus()).isEqualTo("ConfigMap Ready"); + }); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResource.java new file mode 100644 index 0000000000..2e2e53b948 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResource.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.baseapi.simple; + +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("CustomService") +@ShortNames("cs") +public class TestCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceSpec.java new file mode 100644 index 0000000000..eda3c477b2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceSpec.java @@ -0,0 +1,49 @@ +package io.javaoperatorsdk.operator.baseapi.simple; + +public class TestCustomResourceSpec { + + private String configMapName; + + private String key; + + private String value; + + public String getConfigMapName() { + return configMapName; + } + + public void setConfigMapName(String configMapName) { + this.configMapName = configMapName; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public String toString() { + return "TestCustomResourceSpec{" + + "configMapName='" + + configMapName + + '\'' + + ", key='" + + key + + '\'' + + ", value='" + + value + + '\'' + + '}'; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceStatus.java new file mode 100644 index 0000000000..75fadc8e5e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceStatus.java @@ -0,0 +1,19 @@ +package io.javaoperatorsdk.operator.baseapi.simple; + +public class TestCustomResourceStatus { + + private String configMapStatus; + + public String getConfigMapStatus() { + return configMapStatus; + } + + public void setConfigMapStatus(String configMapStatus) { + this.configMapStatus = configMapStatus; + } + + @Override + public String toString() { + return "TestCustomResourceStatus{" + "configMapStatus='" + configMapStatus + '\'' + '}'; + } +} 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 new file mode 100644 index 0000000000..975d5afd9a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestReconciler.java @@ -0,0 +1,135 @@ +package io.javaoperatorsdk.operator.baseapi.simple; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.api.reconciler.*; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration(generationAwareEventProcessing = false) +public class TestReconciler + implements Reconciler, + Cleaner, + TestExecutionInfoProvider { + + private static final Logger log = LoggerFactory.getLogger(TestReconciler.class); + + public static final String FINALIZER_NAME = + ReconcilerUtils.getDefaultFinalizerName(TestCustomResource.class); + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private final AtomicInteger numberOfCleanupExecutions = new AtomicInteger(0); + private volatile boolean updateStatus; + + public TestReconciler(boolean updateStatus) { + this.updateStatus = updateStatus; + } + + 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) { + numberOfExecutions.addAndGet(1); + 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(); + + if (existingConfigMap != null) { + existingConfigMap.setData(configMapData(resource)); + // existingConfigMap.getMetadata().setResourceVersion(null); + kubernetesClient + .configMaps() + .inNamespace(resource.getMetadata().getNamespace()) + .resource(existingConfigMap) + .createOrReplace(); + } else { + Map labels = new HashMap<>(); + labels.put("managedBy", TestReconciler.class.getSimpleName()); + ConfigMap newConfigMap = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(resource.getSpec().getConfigMapName()) + .withNamespace(resource.getMetadata().getNamespace()) + .withLabels(labels) + .build()) + .withData(configMapData(resource)) + .build(); + kubernetesClient + .configMaps() + .inNamespace(resource.getMetadata().getNamespace()) + .resource(newConfigMap) + .createOrReplace(); + } + if (updateStatus) { + var statusUpdateResource = new TestCustomResource(); + statusUpdateResource.setMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .build()); + resource.setStatus(new TestCustomResourceStatus()); + resource.getStatus().setConfigMapStatus("ConfigMap Ready"); + return UpdateControl.patchStatus(resource); + } + return UpdateControl.noUpdate(); + } + + private Map configMapData(TestCustomResource resource) { + Map data = new HashMap<>(); + data.put(resource.getSpec().getKey(), resource.getSpec().getValue()); + return data; + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + public int getNumberOfCleanupExecutions() { + return numberOfCleanupExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueCustomResource.java new file mode 100644 index 0000000000..0e31be2dfb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.ssaissue.finalizer; + +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("ssfi") +public class SSAFinalizerIssueCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueIT.java new file mode 100644 index 0000000000..a830675518 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueIT.java @@ -0,0 +1,66 @@ +package io.javaoperatorsdk.operator.baseapi.ssaissue.finalizer; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.dsl.base.PatchContext; +import io.fabric8.kubernetes.client.dsl.base.PatchType; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class SSAFinalizerIssueIT { + + public static final String TEST_1 = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new SSAFinalizerIssueReconciler()) + .build(); + + /** + * Showcases possibly a case with SSA: When the resource is created with the same field manager + * that used by the controller, when adding a finalizer, it deletes other parts of the spec. + */ + @Test + void addingFinalizerRemoveListValues() { + var fieldManager = + extension + .getRegisteredControllerForReconcile(SSAFinalizerIssueReconciler.class) + .getConfiguration() + .fieldManager(); + + extension + .getKubernetesClient() + .resource(testResource()) + .inNamespace(extension.getNamespace()) + .patch( + new PatchContext.Builder() + .withFieldManager(fieldManager) + .withForce(true) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build()); + + await() + .untilAsserted( + () -> { + var actual = extension.get(SSAFinalizerIssueCustomResource.class, TEST_1); + assertThat(actual.getFinalizers()).hasSize(1); + assertThat(actual.getSpec()).isNull(); + }); + } + + SSAFinalizerIssueCustomResource testResource() { + var res = new SSAFinalizerIssueCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_1).build()); + res.setSpec(new SSAFinalizerIssueSpec()); + res.getSpec().setValue("val"); + res.getSpec().setList(List.of("val1", "val2")); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueReconciler.java new file mode 100644 index 0000000000..441819834a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueReconciler.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.baseapi.ssaissue.finalizer; + +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +@ControllerConfiguration +public class SSAFinalizerIssueReconciler + implements Reconciler, + Cleaner { + + @Override + public DeleteControl cleanup( + SSAFinalizerIssueCustomResource resource, Context context) { + return DeleteControl.defaultDelete(); + } + + @Override + public UpdateControl reconcile( + SSAFinalizerIssueCustomResource resource, Context context) { + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueSpec.java new file mode 100644 index 0000000000..d1c9dd5966 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueSpec.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.baseapi.ssaissue.finalizer; + +import java.util.List; + +public class SSAFinalizerIssueSpec { + + private String value; + + private List list = null; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public List getList() { + return list; + } + + public void setList(List list) { + this.list = list; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueStatus.java new file mode 100644 index 0000000000..471372af40 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/finalizer/SSAFinalizerIssueStatus.java @@ -0,0 +1,19 @@ +package io.javaoperatorsdk.operator.baseapi.ssaissue.finalizer; + +public class SSAFinalizerIssueStatus { + + private String configMapStatus; + + public String getConfigMapStatus() { + return configMapStatus; + } + + public void setConfigMapStatus(String configMapStatus) { + this.configMapStatus = configMapStatus; + } + + @Override + public String toString() { + return "TestCustomResourceStatus{" + "configMapStatus='" + configMapStatus + '\'' + '}'; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateCustomResource.java new file mode 100644 index 0000000000..c4ee2dd1a5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.ssaissue.specupdate; + +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("ssul") +public class SSASpecUpdateCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateCustomResourceSpec.java new file mode 100644 index 0000000000..47953ccce0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateCustomResourceSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.ssaissue.specupdate; + +public class SSASpecUpdateCustomResourceSpec { + + 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/ssaissue/specupdate/SSASpecUpdateCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateCustomResourceStatus.java new file mode 100644 index 0000000000..e5f103dc69 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateCustomResourceStatus.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.ssaissue.specupdate; + +public class SSASpecUpdateCustomResourceStatus { + + private Integer value = 0; + + public Integer getValue() { + return value; + } + + public SSASpecUpdateCustomResourceStatus setValue(Integer value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateIT.java new file mode 100644 index 0000000000..9cccd32e3c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateIT.java @@ -0,0 +1,41 @@ +package io.javaoperatorsdk.operator.baseapi.ssaissue.specupdate; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class SSASpecUpdateIT { + + public static final String TEST_RESOURCE_NAME = "test"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(SSASpecUpdateReconciler.class).build(); + + // showcases that if the spec of the resources is updated with SSA, but the finalizer + // is not explicitly added to the fresh resource, the update removes the finalizer + @Test + void showFinalizerRemovalWhenSpecUpdated() { + SSASpecUpdateCustomResource res = createResource(); + operator.create(res); + + await() + .untilAsserted( + () -> { + var actual = operator.get(SSASpecUpdateCustomResource.class, TEST_RESOURCE_NAME); + assertThat(actual.getSpec()).isNotNull(); + assertThat(actual.getFinalizers()).isEmpty(); + }); + } + + SSASpecUpdateCustomResource createResource() { + SSASpecUpdateCustomResource res = new SSASpecUpdateCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + return res; + } +} 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 new file mode 100644 index 0000000000..483060ad59 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/ssaissue/specupdate/SSASpecUpdateReconciler.java @@ -0,0 +1,47 @@ +package io.javaoperatorsdk.operator.baseapi.ssaissue.specupdate; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +@ControllerConfiguration +public class SSASpecUpdateReconciler + implements Reconciler, Cleaner { + + @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(); + + return UpdateControl.noUpdate(); + } + + SSASpecUpdateCustomResource createFreshCopy(SSASpecUpdateCustomResource resource) { + var res = new SSASpecUpdateCustomResource(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .build()); + res.setSpec(new SSASpecUpdateCustomResourceSpec()); + return res; + } + + @Override + public DeleteControl cleanup( + SSASpecUpdateCustomResource resource, Context context) { + + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessCustomResource.java new file mode 100644 index 0000000000..b9701c94bd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.startsecondaryaccess; + +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("ssac") +public class StartupSecondaryAccessCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessIT.java new file mode 100644 index 0000000000..61fc40803c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessIT.java @@ -0,0 +1,53 @@ +package io.javaoperatorsdk.operator.baseapi.startsecondaryaccess; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.startsecondaryaccess.StartupSecondaryAccessReconciler.LABEL_KEY; +import static io.javaoperatorsdk.operator.baseapi.startsecondaryaccess.StartupSecondaryAccessReconciler.LABEL_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class StartupSecondaryAccessIT { + + public static final int SECONDARY_NUMBER = 200; + + @RegisterExtension + static LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new StartupSecondaryAccessReconciler()) + .withBeforeStartHook( + ex -> { + var primary = new StartupSecondaryAccessCustomResource(); + primary.setMetadata(new ObjectMetaBuilder().withName("test1").build()); + primary = ex.serverSideApply(primary); + + for (int i = 0; i < SECONDARY_NUMBER; i++) { + ConfigMap cm = new ConfigMap(); + cm.setMetadata( + new ObjectMetaBuilder() + .withLabels(Map.of(LABEL_KEY, LABEL_VALUE)) + .withNamespace(ex.getNamespace()) + .withName("cm" + i) + .build()); + cm.addOwnerReference(primary); + ex.serverSideApply(cm); + } + }) + .build(); + + @Test + void reconcilerSeeAllSecondaryResources() { + var reconciler = extension.getReconcilerOfType(StartupSecondaryAccessReconciler.class); + + await().untilAsserted(() -> assertThat(reconciler.isReconciled()).isTrue()); + + assertThat(reconciler.isSecondaryAndCacheSameAmount()).isTrue(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessReconciler.java new file mode 100644 index 0000000000..a2c51fdafd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/startsecondaryaccess/StartupSecondaryAccessReconciler.java @@ -0,0 +1,75 @@ +package io.javaoperatorsdk.operator.baseapi.startsecondaryaccess; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +import static io.javaoperatorsdk.operator.baseapi.startsecondaryaccess.StartupSecondaryAccessIT.SECONDARY_NUMBER; + +@ControllerConfiguration +public class StartupSecondaryAccessReconciler + implements Reconciler { + + private static final Logger log = LoggerFactory.getLogger(StartupSecondaryAccessReconciler.class); + + public static final String LABEL_KEY = "app"; + public static final String LABEL_VALUE = "secondary-test"; + + private InformerEventSource cmInformer; + + private boolean secondaryAndCacheSameAmount = true; + private boolean reconciled = false; + + @Override + public UpdateControl reconcile( + StartupSecondaryAccessCustomResource resource, + Context context) { + + var secondary = context.getSecondaryResources(ConfigMap.class); + var cached = cmInformer.list().toList(); + + log.info( + "Secondary number: {}, cached: {}, expected: {}", + secondary.size(), + cached.size(), + SECONDARY_NUMBER); + + if (secondary.size() != cached.size()) { + secondaryAndCacheSameAmount = false; + } + reconciled = true; + return UpdateControl.noUpdate(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + cmInformer = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, StartupSecondaryAccessCustomResource.class) + .withLabelSelector(LABEL_KEY + "=" + LABEL_VALUE) + .build(), + context); + return List.of(cmInformer); + } + + public boolean isSecondaryAndCacheSameAmount() { + return secondaryAndCacheSameAmount; + } + + public boolean isReconciled() { + return reconciled; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/PeriodicTriggerEventSource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/PeriodicTriggerEventSource.java new file mode 100644 index 0000000000..366777409a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/PeriodicTriggerEventSource.java @@ -0,0 +1,52 @@ +package io.javaoperatorsdk.operator.baseapi.statuscache; + +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.AbstractEventSource; +import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache; + +public class PeriodicTriggerEventSource

+ extends AbstractEventSource { + + public static final int DEFAULT_PERIOD = 30; + private final Timer timer = new Timer(); + private final IndexerResourceCache

primaryCache; + private final int period; + + public PeriodicTriggerEventSource(IndexerResourceCache

primaryCache) { + this(primaryCache, DEFAULT_PERIOD); + } + + public PeriodicTriggerEventSource(IndexerResourceCache

primaryCache, int period) { + super(Void.class); + this.primaryCache = primaryCache; + this.period = period; + } + + @Override + public Set getSecondaryResources(P primary) { + return Set.of(); + } + + @Override + public void start() throws OperatorException { + super.start(); + timer.schedule( + new TimerTask() { + @Override + public void run() { + primaryCache + .list() + .forEach(r -> getEventHandler().handleEvent(new Event(ResourceID.fromResource(r)))); + } + }, + 0, + period); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheCustomResource.java new file mode 100644 index 0000000000..e87d8e8714 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.statuscache; + +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("spwl") +public class StatusPatchCacheCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheIT.java new file mode 100644 index 0000000000..9d0b923056 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheIT.java @@ -0,0 +1,48 @@ +package io.javaoperatorsdk.operator.baseapi.statuscache; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class StatusPatchCacheIT { + + public static final String TEST_1 = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(StatusPatchCacheReconciler.class) + .build(); + + @Test + void testStatusAlwaysUpToDate() { + var reconciler = extension.getReconcilerOfType(StatusPatchCacheReconciler.class); + + extension.create(testResource()); + + // the reconciliation is periodically triggered, the status values should be increasing + // monotonically + await() + .pollDelay(Duration.ofSeconds(1)) + .pollInterval(Duration.ofMillis(30)) + .untilAsserted( + () -> { + assertThat(reconciler.errorPresent).isFalse(); + assertThat(reconciler.latestValue).isGreaterThan(10); + }); + } + + StatusPatchCacheCustomResource testResource() { + var res = new StatusPatchCacheCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_1).build()); + res.setSpec(new StatusPatchCacheSpec()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheReconciler.java new file mode 100644 index 0000000000..69215d6d01 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheReconciler.java @@ -0,0 +1,67 @@ +package io.javaoperatorsdk.operator.baseapi.statuscache; + +import java.util.List; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.PrimaryUpdateAndCacheUtils; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; + +@ControllerConfiguration +public class StatusPatchCacheReconciler implements Reconciler { + + public volatile int latestValue = 0; + public volatile boolean errorPresent = false; + + @Override + public UpdateControl reconcile( + StatusPatchCacheCustomResource resource, Context context) { + + if (resource.getStatus() != null && resource.getStatus().getValue() != latestValue) { + errorPresent = true; + throw new IllegalStateException( + "status is not up to date. Latest value: " + + latestValue + + " status values: " + + resource.getStatus().getValue()); + } + + // test also resource update happening meanwhile reconciliation + resource.getSpec().setCounter(resource.getSpec().getCounter() + 1); + context.getClient().resource(resource).update(); + + var freshCopy = createFreshCopy(resource); + + freshCopy + .getStatus() + .setValue(resource.getStatus() == null ? 1 : resource.getStatus().getValue() + 1); + + var updated = + PrimaryUpdateAndCacheUtils.ssaPatchStatusAndCacheResource(resource, freshCopy, context); + latestValue = updated.getStatus().getValue(); + + return UpdateControl.noUpdate(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + // periodic event triggering for testing purposes + return List.of(new PeriodicTriggerEventSource<>(context.getPrimaryCache())); + } + + private StatusPatchCacheCustomResource createFreshCopy(StatusPatchCacheCustomResource resource) { + var res = new StatusPatchCacheCustomResource(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .build()); + res.setStatus(new StatusPatchCacheStatus()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheSpec.java new file mode 100644 index 0000000000..0885b6a858 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.statuscache; + +public class StatusPatchCacheSpec { + + private int counter = 0; + + public int getCounter() { + return counter; + } + + public void setCounter(int counter) { + this.counter = counter; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheStatus.java new file mode 100644 index 0000000000..5918b2e3b8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuscache/StatusPatchCacheStatus.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.statuscache; + +public class StatusPatchCacheStatus { + + private Integer value = 0; + + public Integer getValue() { + return value; + } + + public StatusPatchCacheStatus setValue(Integer value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingCustomResource.java new file mode 100644 index 0000000000..7e7dc00c9c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingCustomResource.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.statuspatchnonlocking; + +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("spl") +public class StatusPatchLockingCustomResource + extends CustomResource< + StatusPatchLockingCustomResourceSpec, StatusPatchLockingCustomResourceStatus> + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingCustomResourceSpec.java new file mode 100644 index 0000000000..298896a8b5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingCustomResourceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.statuspatchnonlocking; + +public class StatusPatchLockingCustomResourceSpec { + + private boolean messageInStatus = true; + + public boolean isMessageInStatus() { + return messageInStatus; + } + + public StatusPatchLockingCustomResourceSpec setMessageInStatus(boolean messageInStatus) { + this.messageInStatus = messageInStatus; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingCustomResourceStatus.java new file mode 100644 index 0000000000..b4629b8207 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingCustomResourceStatus.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.baseapi.statuspatchnonlocking; + +public class StatusPatchLockingCustomResourceStatus { + + private Integer value = 0; + + private String message; + + public String getMessage() { + return message; + } + + public StatusPatchLockingCustomResourceStatus setMessage(String message) { + this.message = message; + return this; + } + + public Integer getValue() { + return value; + } + + public StatusPatchLockingCustomResourceStatus setValue(Integer value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingReconciler.java new file mode 100644 index 0000000000..79d69ff50b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchLockingReconciler.java @@ -0,0 +1,35 @@ +package io.javaoperatorsdk.operator.baseapi.statuspatchnonlocking; + +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; + +@ControllerConfiguration +public class StatusPatchLockingReconciler implements Reconciler { + + public static final String MESSAGE = "message"; + public static final long WAIT_TIME = 500L; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + StatusPatchLockingCustomResource resource, Context context) + throws InterruptedException { + numberOfExecutions.addAndGet(1); + Thread.sleep(WAIT_TIME); + + if (resource.getStatus() == null) { + resource.setStatus(new StatusPatchLockingCustomResourceStatus()); + } + resource.getStatus().setMessage(resource.getSpec().isMessageInStatus() ? MESSAGE : null); + resource.getStatus().setValue(resource.getStatus().getValue() + 1); + return UpdateControl.patchStatus(resource); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchNotLockingForNonSSAIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchNotLockingForNonSSAIT.java new file mode 100644 index 0000000000..4913b900a0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchNotLockingForNonSSAIT.java @@ -0,0 +1,84 @@ +package io.javaoperatorsdk.operator.baseapi.statuspatchnonlocking; + +import java.time.Duration; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.statuspatchnonlocking.StatusPatchLockingReconciler.MESSAGE; +import static io.javaoperatorsdk.operator.baseapi.statusupdatelocking.StatusUpdateLockingReconciler.WAIT_TIME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class StatusPatchNotLockingForNonSSAIT { + + public static final String TEST_RESOURCE_NAME = "test"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(StatusPatchLockingReconciler.class) + .withConfigurationService(o -> o.withUseSSAToPatchPrimaryResource(false)) + .build(); + + @Test + void noOptimisticLockingDoneOnStatusUpdate() throws InterruptedException { + var resource = operator.create(createResource()); + Thread.sleep(WAIT_TIME / 2); + resource.getMetadata().setAnnotations(Map.of("key", "value")); + operator.replace(resource); + + await() + .pollDelay(Duration.ofMillis(WAIT_TIME)) + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(StatusPatchLockingReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(1); + var actual = operator.get(StatusPatchLockingCustomResource.class, TEST_RESOURCE_NAME); + assertThat(actual.getStatus().getValue()).isEqualTo(1); + assertThat(actual.getMetadata().getGeneration()).isEqualTo(1); + }); + } + + // see https://github.com/fabric8io/kubernetes-client/issues/4158 + @Test + void valuesAreDeletedIfSetToNull() { + var resource = operator.create(createResource()); + + await() + .untilAsserted( + () -> { + var actual = operator.get(StatusPatchLockingCustomResource.class, TEST_RESOURCE_NAME); + assertThat(actual.getStatus()).isNotNull(); + assertThat(actual.getStatus().getMessage()).isEqualTo(MESSAGE); + }); + + // resource needs to be read again to we don't replace the with wrong managed fields + resource = operator.get(StatusPatchLockingCustomResource.class, TEST_RESOURCE_NAME); + resource.getSpec().setMessageInStatus(false); + operator.replace(resource); + + await() + .timeout(Duration.ofMinutes(3)) + .untilAsserted( + () -> { + var actual = operator.get(StatusPatchLockingCustomResource.class, TEST_RESOURCE_NAME); + assertThat(actual.getStatus()).isNotNull(); + assertThat(actual.getStatus().getMessage()).isNull(); + }); + } + + StatusPatchLockingCustomResource createResource() { + StatusPatchLockingCustomResource res = new StatusPatchLockingCustomResource(); + res.setSpec(new StatusPatchLockingCustomResourceSpec()); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchSSAMigrationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchSSAMigrationIT.java new file mode 100644 index 0000000000..a301e9f61a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuspatchnonlocking/StatusPatchSSAMigrationIT.java @@ -0,0 +1,172 @@ +package io.javaoperatorsdk.operator.baseapi.statuspatchnonlocking; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; + +import io.fabric8.kubernetes.api.model.Namespace; +import io.fabric8.kubernetes.api.model.NamespaceBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.fabric8.kubernetes.client.utils.KubernetesResourceUtil; +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class StatusPatchSSAMigrationIT { + + public static final String TEST_RESOURCE_NAME = "test"; + + private final KubernetesClient client = new KubernetesClientBuilder().build(); + private String testNamespace; + + @BeforeEach + void beforeEach(TestInfo testInfo) { + LocallyRunOperatorExtension.applyCrd(StatusPatchLockingCustomResource.class, client); + testInfo + .getTestMethod() + .ifPresent(method -> testNamespace = KubernetesResourceUtil.sanitizeName(method.getName())); + client.namespaces().resource(testNamespace(testNamespace)).create(); + } + + @AfterEach + void afterEach() { + client.namespaces().withName(testNamespace).delete(); + await() + .untilAsserted( + () -> { + var ns = client.namespaces().withName(testNamespace).get(); + assertThat(ns).isNull(); + }); + client.close(); + } + + @Test + void testMigratingToSSA() { + var operator = startOperator(false); + var testResource = client.resource(testResource()).create(); + + await() + .untilAsserted( + () -> { + var res = client.resource(testResource).get(); + assertThat(res.getStatus()).isNotNull(); + assertThat(res.getStatus().getMessage()) + .isEqualTo(StatusPatchLockingReconciler.MESSAGE); + assertThat(res.getStatus().getValue()).isEqualTo(1); + }); + operator.stop(); + + // start operator with SSA + operator = startOperator(true); + await() + .untilAsserted( + () -> { + var res = client.resource(testResource).get(); + assertThat(res.getStatus()).isNotNull(); + assertThat(res.getStatus().getMessage()) + .isEqualTo(StatusPatchLockingReconciler.MESSAGE); + assertThat(res.getStatus().getValue()).isEqualTo(2); + }); + + var actualResource = client.resource(testResource()).get(); + actualResource.getSpec().setMessageInStatus(false); + client.resource(actualResource).update(); + + await() + .untilAsserted( + () -> { + var res = client.resource(testResource).get(); + assertThat(res.getStatus()).isNotNull(); + // !!! This is wrong, the message should be null, + // see issue in Kubernetes: https://github.com/kubernetes/kubernetes/issues/99003 + assertThat(res.getStatus().getMessage()).isNotNull(); + assertThat(res.getStatus().getValue()).isEqualTo(3); + }); + + client.resource(testResource()).delete(); + operator.stop(); + } + + @Test + void workaroundMigratingFromToSSA() { + var operator = startOperator(false); + var testResource = client.resource(testResource()).create(); + + await() + .untilAsserted( + () -> { + var res = client.resource(testResource).get(); + assertThat(res.getStatus()).isNotNull(); + assertThat(res.getStatus().getMessage()) + .isEqualTo(StatusPatchLockingReconciler.MESSAGE); + assertThat(res.getStatus().getValue()).isEqualTo(1); + }); + operator.stop(); + + // start operator with SSA + operator = startOperator(true); + await() + .untilAsserted( + () -> { + var res = client.resource(testResource).get(); + assertThat(res.getStatus()).isNotNull(); + assertThat(res.getStatus().getMessage()) + .isEqualTo(StatusPatchLockingReconciler.MESSAGE); + assertThat(res.getStatus().getValue()).isEqualTo(2); + }); + + var actualResource = client.resource(testResource()).get(); + actualResource.getSpec().setMessageInStatus(false); + // removing the managed field entry for former method works + actualResource + .getMetadata() + .setManagedFields( + actualResource.getMetadata().getManagedFields().stream() + .filter(r -> !r.getOperation().equals("Update") && r.getSubresource() != null) + .toList()); + client.resource(actualResource).update(); + + await() + .untilAsserted( + () -> { + var res = client.resource(testResource).get(); + assertThat(res.getStatus()).isNotNull(); + assertThat(res.getStatus().getMessage()).isNull(); + assertThat(res.getStatus().getValue()).isEqualTo(3); + }); + + client.resource(testResource()).delete(); + operator.stop(); + } + + private Operator startOperator(boolean patchStatusWithSSA) { + var operator = + new Operator( + o -> + o.withCloseClientOnStop(false) + .withUseSSAToPatchPrimaryResource(patchStatusWithSSA)); + operator.register(new StatusPatchLockingReconciler(), o -> o.settingNamespaces(testNamespace)); + + operator.start(); + return operator; + } + + StatusPatchLockingCustomResource testResource() { + StatusPatchLockingCustomResource res = new StatusPatchLockingCustomResource(); + res.setSpec(new StatusPatchLockingCustomResourceSpec()); + res.setMetadata( + new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).withNamespace(testNamespace).build()); + return res; + } + + private Namespace testNamespace(String name) { + return new NamespaceBuilder() + .withMetadata(new ObjectMetaBuilder().withName(name).build()) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingCustomResource.java new file mode 100644 index 0000000000..8f97242215 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingCustomResource.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.statusupdatelocking; + +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("StatusUpdateLockingCustomResource") +@ShortNames("sul") +public class StatusUpdateLockingCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingCustomResourceStatus.java new file mode 100644 index 0000000000..81d6c8f123 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingCustomResourceStatus.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.statusupdatelocking; + +public class StatusUpdateLockingCustomResourceStatus { + + private Integer value = 0; + + public Integer getValue() { + return value; + } + + public StatusUpdateLockingCustomResourceStatus setValue(Integer value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingIT.java new file mode 100644 index 0000000000..ad51d82059 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingIT.java @@ -0,0 +1,58 @@ +package io.javaoperatorsdk.operator.baseapi.statusupdatelocking; + +import java.time.Duration; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.statusupdatelocking.StatusUpdateLockingReconciler.WAIT_TIME; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class StatusUpdateLockingIT { + + public static final String TEST_RESOURCE_NAME = "test"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withConfigurationService(o -> o.withUseSSAToPatchPrimaryResource(false)) + .withReconciler(StatusUpdateLockingReconciler.class) + .build(); + + @Test + void noOptimisticLockingDoneOnStatusPatch() throws InterruptedException { + var resource = operator.create(createResource()); + Thread.sleep(WAIT_TIME / 2); + resource.getMetadata().setAnnotations(Map.of("key", "value")); + operator.replace(resource); + + await() + .pollDelay(Duration.ofMillis(WAIT_TIME)) + .timeout(Duration.ofSeconds(460)) + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(StatusUpdateLockingReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(1); + assertThat( + operator + .get(StatusUpdateLockingCustomResource.class, TEST_RESOURCE_NAME) + .getStatus() + .getValue()) + .isEqualTo(1); + }); + } + + StatusUpdateLockingCustomResource createResource() { + StatusUpdateLockingCustomResource res = new StatusUpdateLockingCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingReconciler.java new file mode 100644 index 0000000000..d6332634fa --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statusupdatelocking/StatusUpdateLockingReconciler.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.baseapi.statusupdatelocking; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; + +@ControllerConfiguration +public class StatusUpdateLockingReconciler + implements Reconciler { + + public static final long WAIT_TIME = 500L; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + StatusUpdateLockingCustomResource resource, + Context context) + throws InterruptedException { + numberOfExecutions.addAndGet(1); + Thread.sleep(WAIT_TIME); + resource.setStatus(new StatusUpdateLockingCustomResourceStatus()); + resource.getStatus().setValue(resource.getStatus().getValue() + 1); + return UpdateControl.patchStatus(resource); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomReconciler.java new file mode 100644 index 0000000000..9c7cfe6609 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomReconciler.java @@ -0,0 +1,48 @@ +package io.javaoperatorsdk.operator.baseapi.subresource; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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; + +import static io.javaoperatorsdk.operator.support.TestUtils.waitXms; + +@ControllerConfiguration(generationAwareEventProcessing = false) +public class SubResourceTestCustomReconciler + implements Reconciler, TestExecutionInfoProvider { + + public static final int RECONCILER_MIN_EXEC_TIME = 300; + + private static final Logger log = LoggerFactory.getLogger(SubResourceTestCustomReconciler.class); + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + SubResourceTestCustomResource resource, Context context) { + numberOfExecutions.addAndGet(1); + log.info("Value: " + resource.getSpec().getValue()); + + ensureStatusExists(resource); + resource.getStatus().setState(SubResourceTestCustomResourceStatus.State.SUCCESS); + waitXms(RECONCILER_MIN_EXEC_TIME); + return UpdateControl.patchStatus(resource); + } + + private void ensureStatusExists(SubResourceTestCustomResource resource) { + SubResourceTestCustomResourceStatus status = resource.getStatus(); + if (status == null) { + status = new SubResourceTestCustomResourceStatus(); + resource.setStatus(status); + } + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResource.java new file mode 100644 index 0000000000..0bd59fc7fe --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResource.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.baseapi.subresource; + +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.Plural; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@Kind("SubresourceSample") +@Plural("subresourcesample") +@ShortNames("ss") +public class SubResourceTestCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceSpec.java new file mode 100644 index 0000000000..9b6c4f8060 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.subresource; + +public class SubResourceTestCustomResourceSpec { + + private String value; + + public String getValue() { + return value; + } + + public SubResourceTestCustomResourceSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceStatus.java new file mode 100644 index 0000000000..d427e432cd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceStatus.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.baseapi.subresource; + +public class SubResourceTestCustomResourceStatus { + + private State state; + + public State getState() { + return state; + } + + public SubResourceTestCustomResourceStatus setState(State state) { + this.state = state; + return this; + } + + public enum State { + SUCCESS, + ERROR + } +} 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 new file mode 100644 index 0000000000..7544e3b791 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java @@ -0,0 +1,120 @@ +package io.javaoperatorsdk.operator.baseapi.subresource; + +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.TestUtils; + +import static io.javaoperatorsdk.operator.baseapi.subresource.SubResourceTestCustomResourceStatus.State.SUCCESS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class SubResourceUpdateIT { + + public static final int WAIT_AFTER_EXECUTION = 500; + public static final int EVENT_RECEIVE_WAIT = 200; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(SubResourceTestCustomReconciler.class) + .build(); + + @Test + void updatesSubResourceStatus() { + SubResourceTestCustomResource resource = createTestCustomResource("1"); + operator.create(resource); + + awaitStatusUpdated(resource.getMetadata().getName()); + // 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); + } + + @Test + void updatesSubResourceStatusNoFinalizer() { + SubResourceTestCustomResource resource = createTestCustomResource("1"); + resource.getMetadata().setFinalizers(Collections.emptyList()); + + operator.create(resource); + + awaitStatusUpdated(resource.getMetadata().getName()); + // 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); + } + + /** Note that we check on controller impl if there is finalizer on execution. */ + @Test + void ifNoFinalizerPresentFirstAddsTheFinalizerThenExecutesControllerAgain() { + SubResourceTestCustomResource resource = createTestCustomResource("1"); + resource.getMetadata().getFinalizers().clear(); + operator.create(resource); + + awaitStatusUpdated(resource.getMetadata().getName()); + // 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); + } + + /** + * The update status actually does optimistic locking in the background but fabric8 client retries + * it with an up-to-date resource version. + */ + @Test + void updateCustomResourceAfterSubResourceChange() { + SubResourceTestCustomResource resource = createTestCustomResource("1"); + resource = operator.create(resource); + + // waits for the resource to start processing + waitXms(EVENT_RECEIVE_WAIT); + resource.getSpec().setValue("new value"); + operator.resources(SubResourceTestCustomResource.class).resource(resource).createOrReplace(); + + awaitStatusUpdated(resource.getMetadata().getName()); + + // wait for sure, there are no more events + waitXms(WAIT_AFTER_EXECUTION); + // note that both is valid, since after the update of the status the event receive lags, + // that will result in a third execution + assertThat(TestUtils.getNumberOfExecutions(operator)).isBetween(2, 3); + } + + void awaitStatusUpdated(String name) { + await("cr status updated") + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + SubResourceTestCustomResource cr = + operator.get(SubResourceTestCustomResource.class, name); + assertThat(cr).isNotNull(); + assertThat(cr.getStatus()).isNotNull(); + assertThat(cr.getStatus().getState()).isEqualTo(SUCCESS); + }); + } + + public SubResourceTestCustomResource createTestCustomResource(String id) { + SubResourceTestCustomResource resource = new SubResourceTestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName("subresource-" + id).build()); + resource.setKind("SubresourceSample"); + resource.setSpec(new SubResourceTestCustomResourceSpec()); + resource.getSpec().setValue(id); + return resource; + } + + public static void waitXms(int x) { + try { + Thread.sleep(x); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartCustomResource.java new file mode 100644 index 0000000000..74846f9a3f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.unmodifiabledependentpart; + +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("udp") +public class UnmodifiableDependentPartCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartIT.java new file mode 100644 index 0000000000..735255c54c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartIT.java @@ -0,0 +1,60 @@ +package io.javaoperatorsdk.operator.baseapi.unmodifiabledependentpart; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.unmodifiabledependentpart.UnmodifiablePartConfigMapDependent.ACTUAL_DATA_KEY; +import static io.javaoperatorsdk.operator.baseapi.unmodifiabledependentpart.UnmodifiablePartConfigMapDependent.UNMODIFIABLE_INITIAL_DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class UnmodifiableDependentPartIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String INITIAL_DATA = "initialData"; + public static final String UPDATED_DATA = "updatedData"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(UnmodifiableDependentPartReconciler.class) + .build(); + + @Test + void partConfigMapDataUnmodifiable() { + var resource = operator.create(testResource()); + + await() + .untilAsserted( + () -> { + var cm = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(cm).isNotNull(); + assertThat(cm.getData()).containsEntry(UNMODIFIABLE_INITIAL_DATA_KEY, INITIAL_DATA); + assertThat(cm.getData()).containsEntry(ACTUAL_DATA_KEY, INITIAL_DATA); + }); + + resource.getSpec().setData(UPDATED_DATA); + operator.replace(resource); + + await() + .untilAsserted( + () -> { + var cm = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(cm).isNotNull(); + assertThat(cm.getData()).containsEntry(UNMODIFIABLE_INITIAL_DATA_KEY, INITIAL_DATA); + assertThat(cm.getData()).containsEntry(ACTUAL_DATA_KEY, UPDATED_DATA); + }); + } + + UnmodifiableDependentPartCustomResource testResource() { + var res = new UnmodifiableDependentPartCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + res.setSpec(new UnmodifiableDependentPartSpec()); + res.getSpec().setData(INITIAL_DATA); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartReconciler.java new file mode 100644 index 0000000000..6defde1d32 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartReconciler.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.baseapi.unmodifiabledependentpart; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow(dependents = {@Dependent(type = UnmodifiablePartConfigMapDependent.class)}) +@ControllerConfiguration +public class UnmodifiableDependentPartReconciler + implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + UnmodifiableDependentPartCustomResource resource, + Context context) + throws InterruptedException { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartSpec.java new file mode 100644 index 0000000000..1c896c75c3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiableDependentPartSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.unmodifiabledependentpart; + +public class UnmodifiableDependentPartSpec { + + private String data; + + public String getData() { + return data; + } + + public UnmodifiableDependentPartSpec setData(String data) { + this.data = data; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiablePartConfigMapDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiablePartConfigMapDependent.java new file mode 100644 index 0000000000..c6f0759410 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/unmodifiabledependentpart/UnmodifiablePartConfigMapDependent.java @@ -0,0 +1,41 @@ +package io.javaoperatorsdk.operator.baseapi.unmodifiabledependentpart; + +import java.util.Map; + +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.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class UnmodifiablePartConfigMapDependent + extends CRUDKubernetesDependentResource { + + public static final String UNMODIFIABLE_INITIAL_DATA_KEY = "initialDataKey"; + public static final String ACTUAL_DATA_KEY = "actualDataKey"; + + @Override + protected ConfigMap desired( + UnmodifiableDependentPartCustomResource primary, + Context context) { + var actual = context.getSecondaryResource(ConfigMap.class); + ConfigMap res = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .build(); + res.setData( + Map.of( + ACTUAL_DATA_KEY, + primary.getSpec().getData(), + // setting the old data if available + UNMODIFIABLE_INITIAL_DATA_KEY, + actual + .map(cm -> cm.getData().get(UNMODIFIABLE_INITIAL_DATA_KEY)) + .orElse(primary.getSpec().getData()))); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleCustomResource.java new file mode 100644 index 0000000000..b3f260b5f1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.baseapi.updatestatusincleanupandreschedule; + +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("usc") +public class UpdateStatusInCleanupAndRescheduleCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleCustomStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleCustomStatus.java new file mode 100644 index 0000000000..b053235f11 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleCustomStatus.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.baseapi.updatestatusincleanupandreschedule; + +public class UpdateStatusInCleanupAndRescheduleCustomStatus { + + private Integer cleanupAttempt; + + public Integer getCleanupAttempt() { + return cleanupAttempt; + } + + public UpdateStatusInCleanupAndRescheduleCustomStatus setCleanupAttempt(Integer cleanupAttempt) { + this.cleanupAttempt = cleanupAttempt; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleIT.java new file mode 100644 index 0000000000..b2a2b463ba --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleIT.java @@ -0,0 +1,58 @@ +package io.javaoperatorsdk.operator.baseapi.updatestatusincleanupandreschedule; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class UpdateStatusInCleanupAndRescheduleIT { + + public static final String TEST_RESOURCE = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(UpdateStatusInCleanupAndRescheduleReconciler.class) + .build(); + + @Test + void testRescheduleAfterPatch() { + var res = extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var resource = + extension.get( + UpdateStatusInCleanupAndRescheduleCustomResource.class, TEST_RESOURCE); + assertThat(resource.getMetadata().getFinalizers()).isNotEmpty(); + }); + + extension.delete(res); + + await() + .untilAsserted( + () -> { + var resource = + extension.get( + UpdateStatusInCleanupAndRescheduleCustomResource.class, TEST_RESOURCE); + assertThat(resource).isNull(); + }); + + assertThat( + extension + .getReconcilerOfType(UpdateStatusInCleanupAndRescheduleReconciler.class) + .getRescheduleDelayWorked()) + .isTrue(); + } + + UpdateStatusInCleanupAndRescheduleCustomResource testResource() { + var resource = new UpdateStatusInCleanupAndRescheduleCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE).build()); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleReconciler.java new file mode 100644 index 0000000000..29b60bdf57 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/updatestatusincleanupandreschedule/UpdateStatusInCleanupAndRescheduleReconciler.java @@ -0,0 +1,62 @@ +package io.javaoperatorsdk.operator.baseapi.updatestatusincleanupandreschedule; + +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; + +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +@ControllerConfiguration +public class UpdateStatusInCleanupAndRescheduleReconciler + implements Reconciler, + Cleaner { + + public static final Integer DELAY = 150; + + private LocalTime lastCleanupExecution; + + public Boolean rescheduleDelayWorked; + + @Override + public UpdateControl reconcile( + UpdateStatusInCleanupAndRescheduleCustomResource resource, + Context context) { + + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup( + UpdateStatusInCleanupAndRescheduleCustomResource resource, + Context context) { + + var status = resource.getStatus(); + if (status == null) { + resource.setStatus(new UpdateStatusInCleanupAndRescheduleCustomStatus()); + resource.getStatus().setCleanupAttempt(1); + lastCleanupExecution = LocalTime.now(); + } else { + var currentAttempt = resource.getStatus().getCleanupAttempt(); + resource.getStatus().setCleanupAttempt(currentAttempt + 1); + if (!Boolean.FALSE.equals(rescheduleDelayWorked)) { + var diff = ChronoUnit.MILLIS.between(lastCleanupExecution, LocalTime.now()); + rescheduleDelayWorked = diff >= DELAY; + } + } + context.getClient().resource(resource).updateStatus(); + + if (resource.getStatus().getCleanupAttempt() > 5) { + return DeleteControl.defaultDelete(); + } else { + return DeleteControl.noFinalizerRemoval().rescheduleAfter(DELAY); + } + } + + public Boolean getRescheduleDelayWorked() { + return rescheduleDelayWorked; + } +} 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 new file mode 100644 index 0000000000..25926e6405 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/BaseConfigurationServiceTest.java @@ -0,0 +1,611 @@ +package io.javaoperatorsdk.operator.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +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.Service; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.config.AnnotationConfigurable; +import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; +import io.javaoperatorsdk.operator.api.config.dependent.ConfigurationConverter; +import io.javaoperatorsdk.operator.api.config.dependent.Configured; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.MaxReconciliationInterval; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ConfiguredDependentResource; +import io.javaoperatorsdk.operator.dependent.dependentssa.DependentSSAReconciler; +import io.javaoperatorsdk.operator.dependent.readonly.ConfigMapReader; +import io.javaoperatorsdk.operator.dependent.readonly.ReadOnlyDependent; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.BooleanWithUndefined; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig; +import io.javaoperatorsdk.operator.processing.event.rate.LinearRateLimiter; +import io.javaoperatorsdk.operator.processing.event.rate.RateLimited; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; +import io.javaoperatorsdk.operator.processing.retry.GradualRetry; +import io.javaoperatorsdk.operator.processing.retry.Retry; +import io.javaoperatorsdk.operator.processing.retry.RetryExecution; + +import static io.javaoperatorsdk.operator.api.reconciler.MaxReconciliationInterval.DEFAULT_INTERVAL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +class BaseConfigurationServiceTest { + + // subclass to expose configFor method to this test class + private static final class TestConfigurationService extends BaseConfigurationService { + + @Override + protected

+ io.javaoperatorsdk.operator.api.config.ControllerConfiguration

configFor( + Reconciler

reconciler) { + return super.configFor(reconciler); + } + } + + private final TestConfigurationService configurationService = new TestConfigurationService(); + + private

+ io.javaoperatorsdk.operator.api.config.ControllerConfiguration

configFor( + Reconciler

reconciler) { + // ensure that a new configuration is created each time + return configurationService.configFor(reconciler); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static KubernetesDependentResourceConfig extractDependentKubernetesResourceConfig( + io.javaoperatorsdk.operator.api.config.ControllerConfiguration configuration, int index) { + final var spec = + configuration.getWorkflowSpec().orElseThrow().getDependentResourceSpecs().get(index); + return (KubernetesDependentResourceConfig) configuration.getConfigurationFor(spec); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void getDependentResources() { + var configuration = configFor(new NoDepReconciler()); + + var workflowSpec = configuration.getWorkflowSpec(); + assertTrue(workflowSpec.isEmpty()); + + configuration = configFor(new OneDepReconciler()); + var dependents = configuration.getWorkflowSpec().orElseThrow().getDependentResourceSpecs(); + assertFalse(dependents.isEmpty()); + assertEquals(1, dependents.size()); + final var dependentResourceName = DependentResource.defaultNameFor(ReadOnlyDependent.class); + assertTrue(dependents.stream().anyMatch(d -> d.getName().equals(dependentResourceName))); + var dependentSpec = findByName(dependents, dependentResourceName); + assertEquals(ReadOnlyDependent.class, dependentSpec.getDependentResourceClass()); + var maybeConfig = extractDependentKubernetesResourceConfig(configuration, 0); + assertNotNull(maybeConfig); + assertInstanceOf(KubernetesDependentResourceConfig.class, maybeConfig); + final var config = (KubernetesDependentResourceConfig) maybeConfig; + + configuration = configFor(new NamedDepReconciler()); + dependents = configuration.getWorkflowSpec().orElseThrow().getDependentResourceSpecs(); + assertFalse(dependents.isEmpty()); + assertEquals(1, dependents.size()); + dependentSpec = findByName(dependents, NamedDepReconciler.NAME); + assertEquals(ReadOnlyDependent.class, dependentSpec.getDependentResourceClass()); + maybeConfig = extractDependentKubernetesResourceConfig(configuration, 0); + assertNotNull(maybeConfig); + assertInstanceOf(KubernetesDependentResourceConfig.class, maybeConfig); + } + + @Test + void missingAnnotationCreatesDefaultConfig() { + final var reconciler = new MissingAnnotationReconciler(); + var config = configFor(reconciler); + + assertThat(config.getName()).isEqualTo(ReconcilerUtils.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())); + + final var informerConfig = config.getInformerConfig(); + assertThat(informerConfig.getLabelSelector()).isNull(); + assertNull(informerConfig.getInformerListLimit()); + assertNull(informerConfig.getOnAddFilter()); + assertNull(informerConfig.getOnUpdateFilter()); + assertNull(informerConfig.getGenericFilter()); + assertNull(informerConfig.getItemStore()); + assertThat(informerConfig.getNamespaces()).isEqualTo(Constants.DEFAULT_NAMESPACES_SET); + } + + @SuppressWarnings("rawtypes") + private DependentResourceSpec findByName( + List dependentResourceSpecList, String name) { + return dependentResourceSpecList.stream() + .filter(d -> d.getName().equals(name)) + .findFirst() + .orElseThrow(); + } + + @SuppressWarnings("rawtypes") + private Optional findByNameOptional( + List dependentResourceSpecList, String name) { + return dependentResourceSpecList.stream().filter(d -> d.getName().equals(name)).findFirst(); + } + + @Test + void tryingToAddDuplicatedDependentsWithoutNameShouldFail() { + final var reconciler = new DuplicatedDepReconciler(); + assertThrows(IllegalArgumentException.class, () -> configFor(reconciler)); + } + + @Test + void addingDuplicatedDependentsWithNameShouldWork() { + var config = configFor(new NamedDuplicatedDepReconciler()); + var dependents = config.getWorkflowSpec().orElseThrow().getDependentResourceSpecs(); + assertEquals(2, dependents.size()); + assertTrue( + findByNameOptional(dependents, NamedDuplicatedDepReconciler.NAME).isPresent() + && findByNameOptional( + dependents, DependentResource.defaultNameFor(ReadOnlyDependent.class)) + .isPresent()); + } + + @Test + void maxIntervalCanBeConfigured() { + var config = configFor(new MaxIntervalReconciler()); + assertEquals(50, config.maxReconciliationInterval().map(Duration::getSeconds).orElseThrow()); + } + + @Test + void checkDefaultRateAndRetryConfigurations() { + var config = configFor(new NoDepReconciler()); + final var retry = assertInstanceOf(GenericRetry.class, config.getRetry()); + assertEquals(GradualRetry.DEFAULT_MAX_ATTEMPTS, retry.getMaxAttempts()); + assertEquals(GradualRetry.DEFAULT_MULTIPLIER, retry.getIntervalMultiplier()); + assertEquals(GradualRetry.DEFAULT_INITIAL_INTERVAL, retry.getInitialInterval()); + assertEquals(GradualRetry.DEFAULT_MAX_INTERVAL, retry.getMaxInterval()); + + final var limiter = assertInstanceOf(LinearRateLimiter.class, config.getRateLimiter()); + assertFalse(limiter.isActivated()); + } + + @Test + void configuringRateAndRetryViaAnnotationsShouldWork() { + var config = configFor(new ConfigurableRateLimitAndRetryReconciler()); + final var retry = config.getRetry(); + final var testRetry = assertInstanceOf(TestRetry.class, retry); + assertEquals(12, testRetry.getValue()); + + final var rateLimiter = assertInstanceOf(LinearRateLimiter.class, config.getRateLimiter()); + assertEquals(7, rateLimiter.getLimitForPeriod()); + assertEquals(Duration.ofSeconds(3), rateLimiter.getRefreshPeriod()); + } + + @Test + void configuringRateLimitAndGradualRetryViaSuperClassShouldWork() { + var config = configFor(new GradualRetryAndRateLimitedOnSuperClass()); + final var retry = config.getRetry(); + final var testRetry = assertInstanceOf(GenericRetry.class, retry); + assertEquals( + BaseClassWithGradualRetryAndRateLimited.RETRY_MAX_ATTEMPTS, testRetry.getMaxAttempts()); + + final var rateLimiter = assertInstanceOf(LinearRateLimiter.class, config.getRateLimiter()); + assertEquals( + BaseClassWithGradualRetryAndRateLimited.RATE_LIMITED_MAX_RECONCILIATIONS, + rateLimiter.getLimitForPeriod()); + assertEquals( + Duration.ofSeconds(BaseClassWithGradualRetryAndRateLimited.RATE_LIMITED_WITHIN_SECONDS), + rateLimiter.getRefreshPeriod()); + } + + @Test + void checkingRetryingGraduallyWorks() { + var config = configFor(new CheckRetryingGraduallyConfiguration()); + final var retry = config.getRetry(); + final var genericRetry = assertInstanceOf(GenericRetry.class, retry); + assertEquals( + CheckRetryingGraduallyConfiguration.INITIAL_INTERVAL, genericRetry.getInitialInterval()); + assertEquals(CheckRetryingGraduallyConfiguration.MAX_ATTEMPTS, genericRetry.getMaxAttempts()); + assertEquals( + CheckRetryingGraduallyConfiguration.INTERVAL_MULTIPLIER, + genericRetry.getIntervalMultiplier()); + assertEquals(CheckRetryingGraduallyConfiguration.MAX_INTERVAL, genericRetry.getMaxInterval()); + } + + @Test + void controllerConfigurationOnSuperClassShouldWork() { + var config = configFor(new ControllerConfigurationOnSuperClass()); + assertNotNull(config.getName()); + } + + @Test + void configuringFromCustomAnnotationsShouldWork() { + var config = configFor(new CustomAnnotationReconciler()); + assertEquals(CustomAnnotatedDep.PROVIDED_VALUE, getValue(config, 0)); + assertEquals(CustomConfigConverter.CONVERTER_PROVIDED_DEFAULT, getValue(config, 1)); + } + + @Test + @SuppressWarnings("unchecked") + void excludedResourceClassesShouldNotUseSSAByDefault() { + final var config = configFor(new SelectorReconciler()); + + // ReadOnlyDependent targets ConfigMap which is configured to not use SSA by default + final var kubernetesDependentResourceConfig = + extractDependentKubernetesResourceConfig(config, 1); + assertNotNull(kubernetesDependentResourceConfig); + assertFalse( + configurationService.shouldUseSSA( + ReadOnlyDependent.class, ConfigMap.class, kubernetesDependentResourceConfig)); + } + + @Test + @SuppressWarnings("unchecked") + void excludedResourceClassesShouldUseSSAIfAnnotatedToDoSo() { + final var config = configFor(new SelectorReconciler()); + + // WithAnnotation dependent also targets ConfigMap but overrides the default with the annotation + final var kubernetesDependentResourceConfig = + extractDependentKubernetesResourceConfig(config, 0); + assertNotNull(kubernetesDependentResourceConfig); + assertTrue(kubernetesDependentResourceConfig.useSSA()); + assertTrue( + configurationService.shouldUseSSA( + SelectorReconciler.WithAnnotation.class, + ConfigMap.class, + kubernetesDependentResourceConfig)); + } + + @Test + @SuppressWarnings("unchecked") + void dependentsShouldUseSSAByDefaultIfNotExcluded() { + final var config = configFor(new DefaultSSAForDependentsReconciler()); + + var kubernetesDependentResourceConfig = extractDependentKubernetesResourceConfig(config, 0); + assertNotNull(kubernetesDependentResourceConfig); + assertTrue( + configurationService.shouldUseSSA( + DefaultSSAForDependentsReconciler.DefaultDependent.class, + ConfigMapReader.class, + kubernetesDependentResourceConfig)); + + kubernetesDependentResourceConfig = extractDependentKubernetesResourceConfig(config, 1); + assertNotNull(kubernetesDependentResourceConfig); + assertFalse(kubernetesDependentResourceConfig.useSSA()); + assertFalse( + configurationService.shouldUseSSA( + DefaultSSAForDependentsReconciler.NonSSADependent.class, + Service.class, + kubernetesDependentResourceConfig)); + } + + @Test + void shouldUseSSAShouldAlsoWorkWithManualConfiguration() { + var reconciler = new DependentSSAReconciler(true); + assertEquals( + reconciler.isUseSSA(), + configurationService.shouldUseSSA(reconciler.getSsaConfigMapDependent())); + + reconciler = new DependentSSAReconciler(false); + assertEquals( + reconciler.isUseSSA(), + configurationService.shouldUseSSA(reconciler.getSsaConfigMapDependent())); + } + + @SuppressWarnings("unchecked") + private static int getValue( + io.javaoperatorsdk.operator.api.config.ControllerConfiguration configuration, int index) { + final var spec = + configuration.getWorkflowSpec().orElseThrow().getDependentResourceSpecs().get(index); + return ((CustomConfig) configuration.getConfigurationFor(spec)).value(); + } + + @ControllerConfiguration( + maxReconciliationInterval = + @MaxReconciliationInterval(interval = 50, timeUnit = TimeUnit.SECONDS)) + private static class MaxIntervalReconciler implements Reconciler { + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + } + + @Workflow(dependents = @Dependent(type = ReadOnlyDependent.class)) + @ControllerConfiguration(informer = @Informer(namespaces = OneDepReconciler.CONFIGURED_NS)) + private static class OneDepReconciler implements Reconciler { + + private static final String CONFIGURED_NS = "foo"; + + @Override + public UpdateControl reconcile( + ConfigMapReader resource, Context context) { + return null; + } + } + + @Workflow(dependents = @Dependent(type = ReadOnlyDependent.class, name = NamedDepReconciler.NAME)) + @ControllerConfiguration + private static class NamedDepReconciler implements Reconciler { + + private static final String NAME = "foo"; + + @Override + public UpdateControl reconcile( + ConfigMapReader resource, Context context) { + return null; + } + } + + @Workflow( + dependents = { + @Dependent(type = ReadOnlyDependent.class), + @Dependent(type = ReadOnlyDependent.class) + }) + @ControllerConfiguration + private static class DuplicatedDepReconciler implements Reconciler { + + @Override + public UpdateControl reconcile( + ConfigMapReader resource, Context context) { + return null; + } + } + + @Workflow( + dependents = { + @Dependent(type = ReadOnlyDependent.class, name = NamedDuplicatedDepReconciler.NAME), + @Dependent(type = ReadOnlyDependent.class) + }) + @ControllerConfiguration + private static class NamedDuplicatedDepReconciler implements Reconciler { + + private static final String NAME = "duplicated"; + + @Override + public UpdateControl reconcile( + ConfigMapReader resource, Context context) { + return null; + } + } + + @ControllerConfiguration + private static class NoDepReconciler implements Reconciler { + + @Override + public UpdateControl reconcile( + ConfigMapReader resource, Context context) { + return null; + } + } + + @Workflow( + dependents = { + @Dependent(type = SelectorReconciler.WithAnnotation.class), + @Dependent(type = ReadOnlyDependent.class) + }) + @ControllerConfiguration + public static class SelectorReconciler implements Reconciler { + + @Override + public UpdateControl reconcile( + ConfigMapReader resource, Context context) { + return null; + } + + @KubernetesDependent(useSSA = BooleanWithUndefined.TRUE) + public static class WithAnnotation + extends CRUDKubernetesDependentResource {} + } + + public static class MissingAnnotationReconciler implements Reconciler { + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + } + + @Workflow( + dependents = { + @Dependent(type = DefaultSSAForDependentsReconciler.DefaultDependent.class), + @Dependent(type = DefaultSSAForDependentsReconciler.NonSSADependent.class) + }) + private static class DefaultSSAForDependentsReconciler implements Reconciler { + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + + private static class DefaultDependent + extends KubernetesDependentResource {} + + @KubernetesDependent(useSSA = BooleanWithUndefined.FALSE) + private static class NonSSADependent extends KubernetesDependentResource {} + } + + public static class TestRetry implements Retry, AnnotationConfigurable { + + private int value; + + public TestRetry() {} + + @Override + public RetryExecution initExecution() { + return null; + } + + public int getValue() { + return value; + } + + @Override + public void initFrom(TestRetryConfiguration configuration) { + value = configuration.value(); + } + } + + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + private @interface TestRetryConfiguration { + + int value() default 42; + } + + @TestRetryConfiguration(12) + @RateLimited(maxReconciliations = 7, within = 3) + @ControllerConfiguration(retry = TestRetry.class) + private static class ConfigurableRateLimitAndRetryReconciler implements Reconciler { + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return UpdateControl.noUpdate(); + } + } + + @GradualRetry( + maxAttempts = CheckRetryingGraduallyConfiguration.MAX_ATTEMPTS, + initialInterval = CheckRetryingGraduallyConfiguration.INITIAL_INTERVAL, + intervalMultiplier = CheckRetryingGraduallyConfiguration.INTERVAL_MULTIPLIER, + maxInterval = CheckRetryingGraduallyConfiguration.MAX_INTERVAL) + @ControllerConfiguration + private static class CheckRetryingGraduallyConfiguration implements Reconciler { + + public static final int MAX_ATTEMPTS = 7; + public static final int INITIAL_INTERVAL = 1000; + public static final int INTERVAL_MULTIPLIER = 2; + public static final int MAX_INTERVAL = 60000; + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return UpdateControl.noUpdate(); + } + } + + @ControllerConfiguration + private static class GradualRetryAndRateLimitedOnSuperClass + extends BaseClassWithGradualRetryAndRateLimited implements Reconciler { + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + } + + @RateLimited( + maxReconciliations = BaseClassWithGradualRetryAndRateLimited.RATE_LIMITED_MAX_RECONCILIATIONS, + within = BaseClassWithGradualRetryAndRateLimited.RATE_LIMITED_WITHIN_SECONDS) + @GradualRetry(maxAttempts = BaseClassWithGradualRetryAndRateLimited.RETRY_MAX_ATTEMPTS) + private static class BaseClassWithGradualRetryAndRateLimited { + + public static final int RATE_LIMITED_MAX_RECONCILIATIONS = 7; + public static final int RATE_LIMITED_WITHIN_SECONDS = 3; + public static final int RETRY_MAX_ATTEMPTS = 3; + } + + private static class ControllerConfigurationOnSuperClass extends BaseClass {} + + @ControllerConfiguration + private static class BaseClass implements Reconciler { + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + } + + @Workflow( + dependents = { + @Dependent(type = CustomAnnotatedDep.class), + @Dependent(type = ChildCustomAnnotatedDep.class) + }) + @ControllerConfiguration() + private static class CustomAnnotationReconciler implements Reconciler { + + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return null; + } + } + + @CustomAnnotation(value = CustomAnnotatedDep.PROVIDED_VALUE) + @Configured( + by = CustomAnnotation.class, + with = CustomConfig.class, + converter = CustomConfigConverter.class) + private static class CustomAnnotatedDep + implements DependentResource, + ConfiguredDependentResource { + + public static final int PROVIDED_VALUE = 42; + private CustomConfig config; + + @Override + public ReconcileResult reconcile(ConfigMap primary, Context context) { + return null; + } + + @Override + public Class resourceType() { + return ConfigMap.class; + } + + @Override + public void configureWith(CustomConfig config) { + this.config = config; + } + + @Override + public Optional configuration() { + return Optional.ofNullable(config); + } + } + + private static class ChildCustomAnnotatedDep extends CustomAnnotatedDep {} + + @Retention(RetentionPolicy.RUNTIME) + private @interface CustomAnnotation { + + int value(); + } + + private record CustomConfig(int value) {} + + private static class CustomConfigConverter + implements ConfigurationConverter { + + static final int CONVERTER_PROVIDED_DEFAULT = 7; + + @Override + public CustomConfig configFrom( + CustomAnnotation configAnnotation, + DependentResourceSpec spec, + io.javaoperatorsdk.operator.api.config.ControllerConfiguration parentConfiguration) { + if (configAnnotation == null) { + return new CustomConfig(CONVERTER_PROVIDED_DEFAULT); + } else { + return new CustomConfig(configAnnotation.value()); + } + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/ControllerConfigurationAnnotationProcessorTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/ControllerConfigurationAnnotationProcessorTest.java new file mode 100644 index 0000000000..a7365c19b9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/ControllerConfigurationAnnotationProcessorTest.java @@ -0,0 +1,46 @@ +package io.javaoperatorsdk.operator.config.runtime; + +import org.junit.jupiter.api.Test; + +import com.google.testing.compile.Compilation; +import com.google.testing.compile.CompilationSubject; +import com.google.testing.compile.Compiler; +import com.google.testing.compile.JavaFileObjects; + +class ControllerConfigurationAnnotationProcessorTest { + + @Test + public void generateCorrectDoneableClassIfInterfaceIsSecond() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ControllerConfigurationAnnotationProcessor()) + .compile( + JavaFileObjects.forResource( + "compile-fixtures/ReconcilerImplemented2Interfaces.java")); + CompilationSubject.assertThat(compilation).succeeded(); + } + + @Test + public void generateCorrectDoneableClassIfThereIsAbstractBaseController() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ControllerConfigurationAnnotationProcessor()) + .compile( + JavaFileObjects.forResource("compile-fixtures/AbstractReconciler.java"), + JavaFileObjects.forResource( + "compile-fixtures/ReconcilerImplementedIntermediateAbstractClass.java")); + CompilationSubject.assertThat(compilation).succeeded(); + } + + @Test + public void generateDoneableClassWithMultilevelHierarchy() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ControllerConfigurationAnnotationProcessor()) + .compile( + JavaFileObjects.forResource("compile-fixtures/AdditionalReconcilerInterface.java"), + JavaFileObjects.forResource("compile-fixtures/MultilevelAbstractReconciler.java"), + JavaFileObjects.forResource("compile-fixtures/MultilevelReconciler.java")); + CompilationSubject.assertThat(compilation).succeeded(); + } +} 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 new file mode 100644 index 0000000000..5a9599840f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java @@ -0,0 +1,86 @@ +package io.javaoperatorsdk.operator.config.runtime; + +import org.junit.jupiter.api.Test; + +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.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 org.junit.jupiter.api.Assertions.*; + +class DefaultConfigurationServiceTest { + + public static final String CUSTOM_FINALIZER_NAME = "a.custom/finalizer"; + final DefaultConfigurationService configurationService = new DefaultConfigurationService(); + + @Test + void returnsValuesFromControllerAnnotationFinalizer() { + final var reconciler = new TestCustomReconciler(); + final var configuration = configurationService.getConfigurationFor(reconciler); + assertEquals( + CustomResource.getCRDName(TestCustomResource.class), configuration.getResourceTypeName()); + assertEquals( + ReconcilerUtils.getDefaultFinalizerName(TestCustomResource.class), + configuration.getFinalizerName()); + assertEquals(TestCustomResource.class, configuration.getResourceClass()); + assertFalse(configuration.isGenerationAware()); + } + + @Test + void returnCustomerFinalizerNameIfSet() { + final var reconciler = new TestCustomFinalizerReconciler(); + final var configuration = configurationService.getConfigurationFor(reconciler); + assertEquals(CUSTOM_FINALIZER_NAME, configuration.getFinalizerName()); + } + + @Test + void supportsInnerClassCustomResources() { + final var reconciler = new TestCustomFinalizerReconciler(); + assertDoesNotThrow( + () -> { + configurationService.getConfigurationFor(reconciler).getAssociatedReconcilerClassName(); + }); + } + + @ControllerConfiguration(finalizerName = CUSTOM_FINALIZER_NAME) + static class TestCustomFinalizerReconciler + implements Reconciler { + + @Override + public UpdateControl reconcile( + InnerCustomResource resource, Context context) { + return null; + } + + @Group("test.crd") + @Version("v1") + public static class InnerCustomResource extends CustomResource {} + } + + @ControllerConfiguration(name = NotAutomaticallyCreated.NAME) + static class NotAutomaticallyCreated implements Reconciler { + + public static final String NAME = "should-be-logged"; + + @Override + public UpdateControl reconcile( + TestCustomResource resource, Context context) { + return null; + } + } + + @ControllerConfiguration(generationAwareEventProcessing = false, name = "test") + static class TestCustomReconciler implements Reconciler { + + @Override + public UpdateControl reconcile( + TestCustomResource resource, Context context) { + return null; + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/TestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/TestCustomResource.java new file mode 100644 index 0000000000..14956f470d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/TestCustomResource.java @@ -0,0 +1,10 @@ +package io.javaoperatorsdk.operator.config.runtime; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +class TestCustomResource extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentDeleterIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentDeleterIT.java new file mode 100644 index 0000000000..108607283b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentDeleterIT.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.dependent.bulkdependent.managed.ManagedDeleterBulkReconciler; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +public class BulkDependentDeleterIT extends BulkDependentTestBase { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new ManagedDeleterBulkReconciler()) + .build(); + + @Override + public LocallyRunOperatorExtension extension() { + return extension; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestBase.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestBase.java new file mode 100644 index 0000000000..49c3b0bb76 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestBase.java @@ -0,0 +1,125 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.dependent.bulkdependent.ConfigMapDeleterBulkDependentResource.LABEL_KEY; +import static io.javaoperatorsdk.operator.dependent.bulkdependent.ConfigMapDeleterBulkDependentResource.LABEL_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public abstract class BulkDependentTestBase { + + public static final String TEST_RESOURCE_NAME = "test"; + public static final int INITIAL_NUMBER_OF_CONFIG_MAPS = 3; + public static final String INITIAL_ADDITIONAL_DATA = "initialData"; + public static final String NEW_VERSION_OF_ADDITIONAL_DATA = "newVersionOfAdditionalData"; + + @Test + public void managesBulkConfigMaps() { + extension().create(testResource()); + assertNumberOfConfigMaps(3); + + updateSpecWithNumber(1); + assertNumberOfConfigMaps(1); + + updateSpecWithNumber(5); + assertNumberOfConfigMaps(5); + + extension().delete(testResource()); + assertNumberOfConfigMaps(0); + } + + @Test + public void updatesData() { + extension().create(testResource()); + assertNumberOfConfigMaps(3); + assertAdditionalDataOnConfigMaps(INITIAL_ADDITIONAL_DATA); + + updateSpecWithNewAdditionalData(NEW_VERSION_OF_ADDITIONAL_DATA); + assertAdditionalDataOnConfigMaps(NEW_VERSION_OF_ADDITIONAL_DATA); + } + + private void assertNumberOfConfigMaps(int n) { + // this test was failing with a lower timeout on GitHub, probably the garbage collection was + // slower there. + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + var cms = + extension() + .getKubernetesClient() + .configMaps() + .inNamespace(extension().getNamespace()) + .withLabel(LABEL_KEY, LABEL_VALUE) + .list() + .getItems(); + assertThat(cms).withFailMessage("Number of items is still: " + cms.size()).hasSize(n); + }); + } + + private void assertAdditionalDataOnConfigMaps(String expectedValue) { + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + var cms = + extension() + .getKubernetesClient() + .configMaps() + .inNamespace(extension().getNamespace()) + .withLabel(LABEL_KEY, LABEL_VALUE) + .list() + .getItems(); + cms.forEach( + cm -> { + assertThat( + cm.getData() + .get(ConfigMapDeleterBulkDependentResource.ADDITIONAL_DATA_KEY)) + .isEqualTo(expectedValue); + }); + }); + } + + public static BulkDependentTestCustomResource testResource() { + BulkDependentTestCustomResource cr = new BulkDependentTestCustomResource(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName(TEST_RESOURCE_NAME); + cr.setSpec(new BulkDependentTestSpec()); + cr.getSpec().setNumberOfResources(INITIAL_NUMBER_OF_CONFIG_MAPS); + cr.getSpec().setAdditionalData(INITIAL_ADDITIONAL_DATA); + return cr; + } + + private void updateSpecWithNewAdditionalData(String data) { + var resource = testResource(); + resource.getSpec().setAdditionalData(data); + extension().replace(resource); + } + + public static void updateSpecWithNewAdditionalData( + LocallyRunOperatorExtension extension, String data) { + var resource = testResource(); + resource.getSpec().setAdditionalData(data); + extension.replace(resource); + } + + private void updateSpecWithNumber(int n) { + var resource = testResource(); + resource.getSpec().setNumberOfResources(n); + extension().replace(resource); + } + + public static void updateSpecWithNumber(LocallyRunOperatorExtension extension, int n) { + var resource = testResource(); + resource.getSpec().setNumberOfResources(n); + extension.replace(resource); + } + + public abstract LocallyRunOperatorExtension extension(); +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestCustomResource.java new file mode 100644 index 0000000000..f9d527ccd0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent; + +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("sbd") +public class BulkDependentTestCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestSpec.java new file mode 100644 index 0000000000..b6cd2b136b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestSpec.java @@ -0,0 +1,25 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent; + +public class BulkDependentTestSpec { + + private Integer numberOfResources; + private String additionalData; + + public Integer getNumberOfResources() { + return numberOfResources; + } + + public BulkDependentTestSpec setNumberOfResources(Integer numberOfResources) { + this.numberOfResources = numberOfResources; + return this; + } + + public BulkDependentTestSpec setAdditionalData(String additionalData) { + this.additionalData = additionalData; + return this; + } + + public String getAdditionalData() { + return additionalData; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestStatus.java new file mode 100644 index 0000000000..38bf94bf0c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/BulkDependentTestStatus.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent; + +public class BulkDependentTestStatus { + + private Boolean ready; + + public Boolean getReady() { + return ready; + } + + public BulkDependentTestStatus setReady(boolean ready) { + this.ready = ready; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/CRUDConfigMapBulkDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/CRUDConfigMapBulkDependentResource.java new file mode 100644 index 0000000000..07652e90dc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/CRUDConfigMapBulkDependentResource.java @@ -0,0 +1,6 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent; + +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; + +public class CRUDConfigMapBulkDependentResource extends ConfigMapDeleterBulkDependentResource + implements GarbageCollected {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/ConfigMapDeleterBulkDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/ConfigMapDeleterBulkDependentResource.java new file mode 100644 index 0000000000..cf3c96b82a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/ConfigMapDeleterBulkDependentResource.java @@ -0,0 +1,64 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.*; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; + +/** Not using CRUDKubernetesDependentResource so the delete functionality can be tested. */ +public class ConfigMapDeleterBulkDependentResource + extends KubernetesDependentResource + implements CRUDBulkDependentResource { + + public static final String LABEL_KEY = "bulk"; + public static final String LABEL_VALUE = "true"; + public static final String ADDITIONAL_DATA_KEY = "additionalData"; + public static final String INDEX_DELIMITER = "-"; + + @Override + public Map desiredResources( + BulkDependentTestCustomResource primary, Context context) { + var number = primary.getSpec().getNumberOfResources(); + Map res = new HashMap<>(); + for (int i = 0; i < number; i++) { + var key = Integer.toString(i); + res.put(key, desired(primary, key)); + } + return res; + } + + public ConfigMap desired(BulkDependentTestCustomResource primary, String key) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName() + INDEX_DELIMITER + key) + .withNamespace(primary.getMetadata().getNamespace()) + .withLabels(Map.of(LABEL_KEY, LABEL_VALUE)) + .build()); + configMap.setData( + Map.of("number", key, ADDITIONAL_DATA_KEY, primary.getSpec().getAdditionalData())); + return configMap; + } + + @Override + public Map getSecondaryResources( + BulkDependentTestCustomResource primary, Context context) { + return context + .getSecondaryResourcesAsStream(ConfigMap.class) + .filter(cm -> getName(cm).startsWith(primary.getMetadata().getName())) + .collect( + Collectors.toMap( + cm -> getName(cm).substring(getName(cm).lastIndexOf(INDEX_DELIMITER) + 1), + Function.identity())); + } + + private static String getName(ConfigMap cm) { + return cm.getMetadata().getName(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/condition/BulkDependentWithConditionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/condition/BulkDependentWithConditionIT.java new file mode 100644 index 0000000000..eb3a6e7368 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/condition/BulkDependentWithConditionIT.java @@ -0,0 +1,50 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.condition; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestBase.INITIAL_NUMBER_OF_CONFIG_MAPS; +import static io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestBase.testResource; +import static io.javaoperatorsdk.operator.dependent.bulkdependent.ConfigMapDeleterBulkDependentResource.LABEL_KEY; +import static io.javaoperatorsdk.operator.dependent.bulkdependent.ConfigMapDeleterBulkDependentResource.LABEL_VALUE; +import static org.assertj.core.api.Assertions.*; +import static org.awaitility.Awaitility.await; + +class BulkDependentWithConditionIT { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new ManagedBulkDependentWithReadyConditionReconciler()) + .build(); + + @Test + void handlesBulkDependentWithPrecondition() { + var resource = testResource(); + extension.create(resource); + + await() + .untilAsserted( + () -> { + var res = + extension.get( + BulkDependentTestCustomResource.class, + testResource().getMetadata().getName()); + assertThat(res.getStatus()).isNotNull(); + assertThat(res.getStatus().getReady()).isTrue(); + + var cms = + extension + .getKubernetesClient() + .configMaps() + .inNamespace(extension.getNamespace()) + .withLabel(LABEL_KEY, LABEL_VALUE) + .list() + .getItems(); + assertThat(cms).hasSize(INITIAL_NUMBER_OF_CONFIG_MAPS); + }); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/condition/ManagedBulkDependentWithReadyConditionReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/condition/ManagedBulkDependentWithReadyConditionReconciler.java new file mode 100644 index 0000000000..dca83304d1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/condition/ManagedBulkDependentWithReadyConditionReconciler.java @@ -0,0 +1,44 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.condition; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestStatus; +import io.javaoperatorsdk.operator.dependent.bulkdependent.CRUDConfigMapBulkDependentResource; + +@Workflow( + dependents = + @Dependent( + readyPostcondition = SampleBulkCondition.class, + type = CRUDConfigMapBulkDependentResource.class)) +@ControllerConfiguration() +public class ManagedBulkDependentWithReadyConditionReconciler + implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + BulkDependentTestCustomResource resource, Context context) + throws Exception { + numberOfExecutions.incrementAndGet(); + + var ready = + context + .managedWorkflowAndDependentResourceContext() + .getWorkflowReconcileResult() + .orElseThrow() + .allDependentResourcesReady(); + + resource.setStatus(new BulkDependentTestStatus()); + resource.getStatus().setReady(ready); + + return UpdateControl.patchStatus(resource); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/condition/SampleBulkCondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/condition/SampleBulkCondition.java new file mode 100644 index 0000000000..b626742ff8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/condition/SampleBulkCondition.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.condition; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; +import io.javaoperatorsdk.operator.dependent.bulkdependent.CRUDConfigMapBulkDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class SampleBulkCondition implements Condition { + + // We use ConfigMaps here just to show how to check some properties of resources managed by a + // BulkDependentResource. In real life example this would be rather based on some status of those + // resources, like Pods. + + @Override + public boolean isMet( + DependentResource dependentResource, + BulkDependentTestCustomResource primary, + Context context) { + + var resources = + ((CRUDConfigMapBulkDependentResource) dependentResource) + .getSecondaryResources(primary, context); + return resources.values().stream().noneMatch(cm -> cm.getData().isEmpty()); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/BulkExternalDependentIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/BulkExternalDependentIT.java new file mode 100644 index 0000000000..4f7d425729 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/BulkExternalDependentIT.java @@ -0,0 +1,55 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.external; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestBase.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class BulkExternalDependentIT { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new ExternalBulkResourceReconciler()) + .build(); + + ExternalServiceMock externalServiceMock = ExternalServiceMock.getInstance(); + + @Test + void managesExternalBulkResources() { + extension.create(testResource()); + assertResourceNumberAndData(3, INITIAL_ADDITIONAL_DATA); + + updateSpecWithNumber(extension, 1); + assertResourceNumberAndData(1, INITIAL_ADDITIONAL_DATA); + + updateSpecWithNumber(extension, 5); + assertResourceNumberAndData(5, INITIAL_ADDITIONAL_DATA); + + extension.delete(testResource()); + assertResourceNumberAndData(0, INITIAL_ADDITIONAL_DATA); + } + + @Test + void handlesResourceUpdates() { + extension.create(testResource()); + assertResourceNumberAndData(3, INITIAL_ADDITIONAL_DATA); + + updateSpecWithNewAdditionalData(extension, NEW_VERSION_OF_ADDITIONAL_DATA); + assertResourceNumberAndData(3, NEW_VERSION_OF_ADDITIONAL_DATA); + } + + private void assertResourceNumberAndData(int n, String data) { + await() + .untilAsserted( + () -> { + var resources = externalServiceMock.listResources(); + assertThat(resources).hasSize(n); + assertThat(resources).allMatch(r -> r.getData().equals(data)); + }); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalBulkDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalBulkDependentResource.java new file mode 100644 index 0000000000..89aa6c4ff3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalBulkDependentResource.java @@ -0,0 +1,120 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.external; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; +import io.javaoperatorsdk.operator.processing.dependent.BulkDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.BulkUpdater; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.external.PollingDependentResource; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public class ExternalBulkDependentResource + extends PollingDependentResource + implements BulkDependentResource, + Creator, + Deleter, + BulkUpdater { + + public static final String EXTERNAL_RESOURCE_NAME_DELIMITER = "#"; + + private final ExternalServiceMock externalServiceMock = ExternalServiceMock.getInstance(); + + public ExternalBulkDependentResource() { + super(ExternalResource.class, ExternalResource::getId); + } + + @Override + public Map> fetchResources() { + Map> result = new HashMap<>(); + var resources = externalServiceMock.listResources(); + resources.forEach( + er -> { + var resourceID = toResourceID(er); + result.putIfAbsent(resourceID, new HashSet<>()); + result.get(resourceID).add(er); + }); + return result; + } + + @Override + public ExternalResource create( + ExternalResource desired, + BulkDependentTestCustomResource primary, + Context context) { + return externalServiceMock.create(desired); + } + + @Override + public ExternalResource update( + ExternalResource actual, + ExternalResource desired, + BulkDependentTestCustomResource primary, + Context context) { + return externalServiceMock.update(desired); + } + + private static String toExternalResourceId(BulkDependentTestCustomResource primary, String i) { + return primary.getMetadata().getName() + + EXTERNAL_RESOURCE_NAME_DELIMITER + + primary.getMetadata().getNamespace() + + EXTERNAL_RESOURCE_NAME_DELIMITER + + i; + } + + private ResourceID toResourceID(ExternalResource externalResource) { + var parts = externalResource.getId().split(EXTERNAL_RESOURCE_NAME_DELIMITER); + return new ResourceID(parts[0], parts[1]); + } + + @Override + public Map desiredResources( + BulkDependentTestCustomResource primary, Context context) { + var number = primary.getSpec().getNumberOfResources(); + Map res = new HashMap<>(); + for (int i = 0; i < number; i++) { + var key = Integer.toString(i); + res.put( + key, + new ExternalResource( + toExternalResourceId(primary, key), primary.getSpec().getAdditionalData())); + } + return res; + } + + @Override + public Map getSecondaryResources( + BulkDependentTestCustomResource primary, Context context) { + return context + .getSecondaryResourcesAsStream(resourceType()) + .filter( + r -> + r.getId() + .startsWith( + primary.getMetadata().getName() + + EXTERNAL_RESOURCE_NAME_DELIMITER + + primary.getMetadata().getNamespace() + + EXTERNAL_RESOURCE_NAME_DELIMITER)) + .collect( + Collectors.toMap( + r -> + r.getId() + .substring(r.getId().lastIndexOf(EXTERNAL_RESOURCE_NAME_DELIMITER) + 1), + r -> r)); + } + + @Override + public void deleteTargetResource( + BulkDependentTestCustomResource primary, + ExternalResource resource, + String key, + Context context) { + externalServiceMock.delete(resource.getId()); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalBulkResourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalBulkResourceReconciler.java new file mode 100644 index 0000000000..1668a1b5d9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalBulkResourceReconciler.java @@ -0,0 +1,17 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.external; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; + +@Workflow(dependents = @Dependent(type = ExternalBulkDependentResource.class)) +@ControllerConfiguration() +public class ExternalBulkResourceReconciler implements Reconciler { + + @Override + public UpdateControl reconcile( + BulkDependentTestCustomResource resource, Context context) + throws Exception { + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalResource.java new file mode 100644 index 0000000000..41c5fa5095 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalResource.java @@ -0,0 +1,35 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.external; + +import java.util.Objects; + +public class ExternalResource { + + private final String id; + private final String data; + + public ExternalResource(String id, String data) { + this.id = id; + this.data = data; + } + + public String getId() { + return id; + } + + public String getData() { + return data; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ExternalResource that = (ExternalResource) o; + return Objects.equals(id, that.id) && Objects.equals(data, that.data); + } + + @Override + public int hashCode() { + return Objects.hash(id, data); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalServiceMock.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalServiceMock.java new file mode 100644 index 0000000000..16008be9ff --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/external/ExternalServiceMock.java @@ -0,0 +1,39 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.external; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class ExternalServiceMock { + + private static final ExternalServiceMock serviceMock = new ExternalServiceMock(); + + private final Map resourceMap = new ConcurrentHashMap<>(); + + public ExternalResource create(ExternalResource externalResource) { + resourceMap.put(externalResource.getId(), externalResource); + return externalResource; + } + + public Optional read(String id) { + return Optional.ofNullable(resourceMap.get(id)); + } + + public ExternalResource update(ExternalResource externalResource) { + return resourceMap.put(externalResource.getId(), externalResource); + } + + public Optional delete(String id) { + return Optional.ofNullable(resourceMap.remove(id)); + } + + public List listResources() { + return new ArrayList<>(resourceMap.values()); + } + + public static ExternalServiceMock getInstance() { + return serviceMock; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/managed/ManagedBulkDependentIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/managed/ManagedBulkDependentIT.java new file mode 100644 index 0000000000..61b7f62a24 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/managed/ManagedBulkDependentIT.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.managed; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestBase; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +public class ManagedBulkDependentIT extends BulkDependentTestBase { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new ManagedBulkDependentReconciler()) + .build(); + + @Override + public LocallyRunOperatorExtension extension() { + return extension; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/managed/ManagedBulkDependentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/managed/ManagedBulkDependentReconciler.java new file mode 100644 index 0000000000..b0361a8edf --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/managed/ManagedBulkDependentReconciler.java @@ -0,0 +1,24 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.managed; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; +import io.javaoperatorsdk.operator.dependent.bulkdependent.CRUDConfigMapBulkDependentResource; + +@Workflow(dependents = @Dependent(type = CRUDConfigMapBulkDependentResource.class)) +@ControllerConfiguration +public class ManagedBulkDependentReconciler implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + BulkDependentTestCustomResource resource, Context context) + throws Exception { + + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/managed/ManagedDeleterBulkReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/managed/ManagedDeleterBulkReconciler.java new file mode 100644 index 0000000000..48fb5dcfce --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/managed/ManagedDeleterBulkReconciler.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.managed; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; +import io.javaoperatorsdk.operator.dependent.bulkdependent.ConfigMapDeleterBulkDependentResource; + +@Workflow(dependents = @Dependent(type = ConfigMapDeleterBulkDependentResource.class)) +@ControllerConfiguration +public class ManagedDeleterBulkReconciler implements Reconciler { + @Override + public UpdateControl reconcile( + BulkDependentTestCustomResource resource, Context context) + throws Exception { + + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkDependentIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkDependentIT.java new file mode 100644 index 0000000000..422360b7b2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkDependentIT.java @@ -0,0 +1,72 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.readonly; + +import java.time.Duration; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestSpec; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class ReadOnlyBulkDependentIT { + + public static final int EXPECTED_NUMBER_OF_RESOURCES = 2; + public static final String TEST = "test"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(new ReadOnlyBulkReconciler()).build(); + + @Test + void readOnlyBulkDependent() { + var primary = extension.create(testCustomResource()); + + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted( + () -> { + var actualPrimary = extension.get(BulkDependentTestCustomResource.class, TEST); + + assertThat(actualPrimary.getStatus()).isNotNull(); + assertThat(actualPrimary.getStatus().getReady()).isFalse(); + }); + + var configMap1 = createConfigMap(1, primary); + extension.create(configMap1); + var configMap2 = createConfigMap(2, primary); + extension.create(configMap2); + + await() + .untilAsserted( + () -> { + var actualPrimary = extension.get(BulkDependentTestCustomResource.class, TEST); + assertThat(actualPrimary.getStatus().getReady()).isTrue(); + }); + } + + private ConfigMap createConfigMap(int i, BulkDependentTestCustomResource primary) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(TEST + ReadOnlyBulkDependentResource.INDEX_DELIMITER + i) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.addOwnerReference(primary); + return configMap; + } + + BulkDependentTestCustomResource testCustomResource() { + BulkDependentTestCustomResource customResource = new BulkDependentTestCustomResource(); + customResource.setMetadata(new ObjectMetaBuilder().withName(TEST).build()); + customResource.setSpec(new BulkDependentTestSpec()); + customResource.getSpec().setNumberOfResources(EXPECTED_NUMBER_OF_RESOURCES); + + return customResource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkDependentResource.java new file mode 100644 index 0000000000..1eab400888 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkDependentResource.java @@ -0,0 +1,47 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.readonly; + +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; +import io.javaoperatorsdk.operator.processing.dependent.BulkDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers; + +@KubernetesDependent +public class ReadOnlyBulkDependentResource + extends KubernetesDependentResource + implements BulkDependentResource, + SecondaryToPrimaryMapper { + + public static final String INDEX_DELIMITER = "-"; + + @Override + public Map getSecondaryResources( + BulkDependentTestCustomResource primary, Context context) { + return context + .getSecondaryResourcesAsStream(ConfigMap.class) + .filter(cm -> getName(cm).startsWith(primary.getMetadata().getName())) + .collect( + Collectors.toMap( + cm -> getName(cm).substring(getName(cm).lastIndexOf(INDEX_DELIMITER) + 1), + Function.identity())); + } + + private static String getName(ConfigMap cm) { + return cm.getMetadata().getName(); + } + + @Override + public Set toPrimaryResourceIDs(ConfigMap resource) { + return Mappers.fromOwnerReferences(BulkDependentTestCustomResource.class, false) + .toPrimaryResourceIDs(resource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkReadyPostCondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkReadyPostCondition.java new file mode 100644 index 0000000000..a7fb40fcc0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkReadyPostCondition.java @@ -0,0 +1,24 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.readonly; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; +import io.javaoperatorsdk.operator.processing.dependent.BulkDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class ReadOnlyBulkReadyPostCondition + implements Condition { + @Override + public boolean isMet( + DependentResource dependentResource, + BulkDependentTestCustomResource primary, + Context context) { + var minResourceNumber = primary.getSpec().getNumberOfResources(); + @SuppressWarnings("unchecked") + var secondaryResources = + ((BulkDependentResource) dependentResource) + .getSecondaryResources(primary, context); + return minResourceNumber <= secondaryResources.size(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkReconciler.java new file mode 100644 index 0000000000..7d43777f12 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/readonly/ReadOnlyBulkReconciler.java @@ -0,0 +1,39 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.readonly; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestStatus; + +@Workflow( + dependents = + @Dependent( + type = ReadOnlyBulkDependentResource.class, + readyPostcondition = ReadOnlyBulkReadyPostCondition.class)) +@ControllerConfiguration +public class ReadOnlyBulkReconciler implements Reconciler { + @Override + public UpdateControl reconcile( + BulkDependentTestCustomResource resource, Context context) { + + var nonReadyDependents = + context + .managedWorkflowAndDependentResourceContext() + .getWorkflowReconcileResult() + .orElseThrow() + .getNotReadyDependents(); + + BulkDependentTestCustomResource customResource = new BulkDependentTestCustomResource(); + customResource.setMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .build()); + var status = new BulkDependentTestStatus(); + status.setReady(nonReadyDependents.isEmpty()); + customResource.setStatus(status); + + return UpdateControl.patchStatus(customResource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/standalone/StandaloneBulkDependentIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/standalone/StandaloneBulkDependentIT.java new file mode 100644 index 0000000000..a74102db0d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/standalone/StandaloneBulkDependentIT.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.standalone; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestBase; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +class StandaloneBulkDependentIT extends BulkDependentTestBase { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new StandaloneBulkDependentReconciler()) + .build(); + + @Override + public LocallyRunOperatorExtension extension() { + return extension; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/standalone/StandaloneBulkDependentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/standalone/StandaloneBulkDependentReconciler.java new file mode 100644 index 0000000000..6aa87737d4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/bulkdependent/standalone/StandaloneBulkDependentReconciler.java @@ -0,0 +1,44 @@ +package io.javaoperatorsdk.operator.dependent.bulkdependent.standalone; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.dependent.bulkdependent.BulkDependentTestCustomResource; +import io.javaoperatorsdk.operator.dependent.bulkdependent.CRUDConfigMapBulkDependentResource; +import io.javaoperatorsdk.operator.dependent.bulkdependent.ConfigMapDeleterBulkDependentResource; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration +public class StandaloneBulkDependentReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + private final ConfigMapDeleterBulkDependentResource dependent; + + public StandaloneBulkDependentReconciler() { + dependent = new CRUDConfigMapBulkDependentResource(); + } + + @Override + public UpdateControl reconcile( + BulkDependentTestCustomResource resource, Context context) { + numberOfExecutions.addAndGet(1); + + dependent.reconcile(resource, context); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + return List.of(dependent.initEventSource(context)); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/CleanerForManagedDependentCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/CleanerForManagedDependentCustomResource.java new file mode 100644 index 0000000000..279cf96d2e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/CleanerForManagedDependentCustomResource.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.cleanermanageddependent; + +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("CleanerForReconcilerCustomResource") +@ShortNames("cfr") +public class CleanerForManagedDependentCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/CleanerForManagedDependentResourcesOnlyIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/CleanerForManagedDependentResourcesOnlyIT.java new file mode 100644 index 0000000000..b0b716e8e2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/CleanerForManagedDependentResourcesOnlyIT.java @@ -0,0 +1,57 @@ +package io.javaoperatorsdk.operator.dependent.cleanermanageddependent; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class CleanerForManagedDependentResourcesOnlyIT { + + public static final String TEST_RESOURCE_NAME = "cleaner-for-reconciler-test1"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new CleanerForManagedDependentTestReconciler()) + .build(); + + @Test + void addsFinalizerAndCallsCleanupIfCleanerImplemented() { + var testResource = createTestResource(); + operator.create(testResource); + + await() + .until( + () -> + !operator + .get(CleanerForManagedDependentCustomResource.class, TEST_RESOURCE_NAME) + .getMetadata() + .getFinalizers() + .isEmpty()); + + operator.delete(testResource); + + await() + .until( + () -> + operator.get(CleanerForManagedDependentCustomResource.class, TEST_RESOURCE_NAME) + == null); + + CleanerForManagedDependentTestReconciler reconciler = + (CleanerForManagedDependentTestReconciler) operator.getFirstReconciler(); + + assertThat(reconciler.getNumberOfExecutions()).isEqualTo(1); + assertThat(ConfigMapDependentResource.getNumberOfCleanupExecutions()).isEqualTo(1); + } + + private CleanerForManagedDependentCustomResource createTestResource() { + CleanerForManagedDependentCustomResource cr = new CleanerForManagedDependentCustomResource(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName(TEST_RESOURCE_NAME); + return cr; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/CleanerForManagedDependentTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/CleanerForManagedDependentTestReconciler.java new file mode 100644 index 0000000000..d3181d62f1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/CleanerForManagedDependentTestReconciler.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.dependent.cleanermanageddependent; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow(dependents = {@Dependent(type = ConfigMapDependentResource.class)}) +@ControllerConfiguration +public class CleanerForManagedDependentTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + CleanerForManagedDependentCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/ConfigMapDependentResource.java new file mode 100644 index 0000000000..3c94775045 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/cleanermanageddependent/ConfigMapDependentResource.java @@ -0,0 +1,53 @@ +package io.javaoperatorsdk.operator.dependent.cleanermanageddependent; + +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Updater; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; + +public class ConfigMapDependentResource + extends KubernetesDependentResource + implements Creator, + Updater, + Deleter { + + private static final AtomicInteger numberOfCleanupExecutions = new AtomicInteger(0); + + @Override + protected ConfigMap desired( + CleanerForManagedDependentCustomResource primary, + Context context) { + + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new ObjectMeta()); + configMap.getMetadata().setName(primary.getMetadata().getName()); + configMap.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + HashMap data = new HashMap<>(); + data.put("key1", "val1"); + configMap.setData(data); + return configMap; + } + + @Override + public void delete( + CleanerForManagedDependentCustomResource primary, + Context context) { + super.delete(primary, context); + numberOfCleanupExecutions.incrementAndGet(); + } + + @Override + protected boolean addOwnerReference() { + return true; + } + + public static int getNumberOfCleanupExecutions() { + return numberOfCleanupExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/ConfigMapDependentResource.java new file mode 100644 index 0000000000..2e37413766 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/ConfigMapDependentResource.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.dependent.createonlyifnotexistsdependentwithssa; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class ConfigMapDependentResource + extends CRUDKubernetesDependentResource< + ConfigMap, CreateOnlyIfNotExistingDependentWithSSACustomResource> { + + public static final String DRKEY = "drkey"; + + @Override + protected ConfigMap desired( + CreateOnlyIfNotExistingDependentWithSSACustomResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of(DRKEY, "v")); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/CreateOnlyIfNotExistingDependentWithSSACustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/CreateOnlyIfNotExistingDependentWithSSACustomResource.java new file mode 100644 index 0000000000..0b2e8b1ef6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/CreateOnlyIfNotExistingDependentWithSSACustomResource.java @@ -0,0 +1,11 @@ +package io.javaoperatorsdk.operator.dependent.createonlyifnotexistsdependentwithssa; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class CreateOnlyIfNotExistingDependentWithSSACustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/CreateOnlyIfNotExistingDependentWithSSAIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/CreateOnlyIfNotExistingDependentWithSSAIT.java new file mode 100644 index 0000000000..3c41bae977 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/CreateOnlyIfNotExistingDependentWithSSAIT.java @@ -0,0 +1,58 @@ +package io.javaoperatorsdk.operator.dependent.createonlyifnotexistsdependentwithssa; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; + +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.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class CreateOnlyIfNotExistingDependentWithSSAIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String KEY = "key"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + // for the sake of this test, we allow to manage ConfigMaps with SSA + // by removing it from the non SSA resources (it is not managed with SSA by default) + .withConfigurationService(o -> o.withDefaultNonSSAResource(Set.of())) + .withReconciler(new CreateOnlyIfNotExistingDependentWithSSAReconciler()) + .build(); + + @Test + void createsResourceOnlyIfNotExisting() { + var cm = + new ConfigMapBuilder() + .withMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()) + .withData(Map.of(KEY, "val")) + .build(); + + extension.create(cm); + extension.create(testResource()); + + await() + .pollDelay(Duration.ofMillis(200)) + .untilAsserted( + () -> { + var currentCM = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(currentCM.getData()).containsOnlyKeys(KEY); + }); + } + + CreateOnlyIfNotExistingDependentWithSSACustomResource testResource() { + var res = new CreateOnlyIfNotExistingDependentWithSSACustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/CreateOnlyIfNotExistingDependentWithSSAReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/CreateOnlyIfNotExistingDependentWithSSAReconciler.java new file mode 100644 index 0000000000..fbfc7e1a6d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/createonlyifnotexistsdependentwithssa/CreateOnlyIfNotExistingDependentWithSSAReconciler.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.dependent.createonlyifnotexistsdependentwithssa; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow(dependents = {@Dependent(type = ConfigMapDependentResource.class)}) +@ControllerConfiguration() +public class CreateOnlyIfNotExistingDependentWithSSAReconciler + implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + CreateOnlyIfNotExistingDependentWithSSACustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentannotationsecondarymapper/DependentAnnotationSecondaryMapperIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentannotationsecondarymapper/DependentAnnotationSecondaryMapperIT.java new file mode 100644 index 0000000000..466837de4e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentannotationsecondarymapper/DependentAnnotationSecondaryMapperIT.java @@ -0,0 +1,60 @@ +package io.javaoperatorsdk.operator.dependent.dependentannotationsecondarymapper; + +import java.time.Duration; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.processing.event.source.informer.Mappers.DEFAULT_ANNOTATION_FOR_NAME; +import static io.javaoperatorsdk.operator.processing.event.source.informer.Mappers.DEFAULT_ANNOTATION_FOR_NAMESPACE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class DependentAnnotationSecondaryMapperIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(DependentAnnotationSecondaryMapperReconciler.class) + .build(); + + @Test + void mapsSecondaryByAnnotation() { + operator.create(testResource()); + + var reconciler = + operator.getReconcilerOfType(DependentAnnotationSecondaryMapperReconciler.class); + + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted(() -> assertThat(reconciler.getNumberOfExecutions()).isEqualTo(1)); + var configMap = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + + var annotations = configMap.getMetadata().getAnnotations(); + + assertThat(annotations) + .containsEntry(DEFAULT_ANNOTATION_FOR_NAME, TEST_RESOURCE_NAME) + .containsEntry(DEFAULT_ANNOTATION_FOR_NAMESPACE, operator.getNamespace()); + + assertThat(configMap.getMetadata().getOwnerReferences()).isEmpty(); + + configMap.getData().put("additional_data", "data"); + operator.replace(configMap); + + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted(() -> assertThat(reconciler.getNumberOfExecutions()).isEqualTo(2)); + } + + DependentAnnotationSecondaryMapperResource testResource() { + var res = new DependentAnnotationSecondaryMapperResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentannotationsecondarymapper/DependentAnnotationSecondaryMapperReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentannotationsecondarymapper/DependentAnnotationSecondaryMapperReconciler.java new file mode 100644 index 0000000000..71146df638 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentannotationsecondarymapper/DependentAnnotationSecondaryMapperReconciler.java @@ -0,0 +1,58 @@ +package io.javaoperatorsdk.operator.dependent.dependentannotationsecondarymapper; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Updater; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow( + dependents = + @Dependent( + type = DependentAnnotationSecondaryMapperReconciler.ConfigMapDependentResource.class)) +@ControllerConfiguration +public class DependentAnnotationSecondaryMapperReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + DependentAnnotationSecondaryMapperResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + public static class ConfigMapDependentResource + extends KubernetesDependentResource + implements Creator, + Updater, + Deleter { + + @Override + protected ConfigMap desired( + DependentAnnotationSecondaryMapperResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of("data", primary.getMetadata().getName())); + return configMap; + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentannotationsecondarymapper/DependentAnnotationSecondaryMapperResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentannotationsecondarymapper/DependentAnnotationSecondaryMapperResource.java new file mode 100644 index 0000000000..c9e8639573 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentannotationsecondarymapper/DependentAnnotationSecondaryMapperResource.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.dependentannotationsecondarymapper; + +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("MaxIntervalTestCustomResource") +@ShortNames("mit") +public class DependentAnnotationSecondaryMapperResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/CustomMappingConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/CustomMappingConfigMapDependentResource.java new file mode 100644 index 0000000000..081cf31dbd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/CustomMappingConfigMapDependentResource.java @@ -0,0 +1,58 @@ +package io.javaoperatorsdk.operator.dependent.dependentcustommappingannotation; + +import java.util.Map; +import java.util.Set; + +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.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers; + +@KubernetesDependent +public class CustomMappingConfigMapDependentResource + extends CRUDNoGCKubernetesDependentResource + implements SecondaryToPrimaryMapper { + + public static final String CUSTOM_NAME_KEY = "customNameKey"; + public static final String CUSTOM_NAMESPACE_KEY = "customNamespaceKey"; + public static final String CUSTOM_TYPE_KEY = "customTypeKey"; + public static final String KEY = "key"; + + private static final SecondaryToPrimaryMapper mapper = + Mappers.fromAnnotation( + CUSTOM_NAME_KEY, + CUSTOM_NAMESPACE_KEY, + CUSTOM_TYPE_KEY, + DependentCustomMappingCustomResource.class); + + @Override + protected ConfigMap desired( + DependentCustomMappingCustomResource primary, + Context context) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of(KEY, primary.getSpec().getValue())) + .build(); + } + + @Override + protected void addSecondaryToPrimaryMapperAnnotations( + ConfigMap desired, DependentCustomMappingCustomResource primary) { + addSecondaryToPrimaryMapperAnnotations( + desired, primary, CUSTOM_NAME_KEY, CUSTOM_NAMESPACE_KEY, CUSTOM_TYPE_KEY); + } + + @Override + public Set toPrimaryResourceIDs(ConfigMap resource) { + return mapper.toPrimaryResourceIDs(resource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingAnnotationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingAnnotationIT.java new file mode 100644 index 0000000000..42f365884f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingAnnotationIT.java @@ -0,0 +1,68 @@ +package io.javaoperatorsdk.operator.dependent.dependentcustommappingannotation; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.dependent.dependentcustommappingannotation.CustomMappingConfigMapDependentResource.CUSTOM_NAMESPACE_KEY; +import static io.javaoperatorsdk.operator.dependent.dependentcustommappingannotation.CustomMappingConfigMapDependentResource.CUSTOM_NAME_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class DependentCustomMappingAnnotationIT { + + public static final String INITIAL_VALUE = "initial value"; + public static final String CHANGED_VALUE = "changed value"; + public static final String TEST_RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(DependentCustomMappingReconciler.class) + .build(); + + @Test + void testCustomMappingAnnotationForDependent() { + var cr = extension.create(testResource()); + assertConfigMapData(INITIAL_VALUE); + + cr.getSpec().setValue(CHANGED_VALUE); + cr = extension.replace(cr); + assertConfigMapData(CHANGED_VALUE); + + extension.delete(cr); + + await() + .untilAsserted( + () -> { + var resource = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(resource).isNull(); + }); + } + + private void assertConfigMapData(String val) { + await() + .untilAsserted( + () -> { + var resource = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(resource).isNotNull(); + assertThat(resource.getMetadata().getAnnotations()) + .containsKey(CUSTOM_NAME_KEY) + .containsKey(CUSTOM_NAMESPACE_KEY); + assertThat(resource.getData()) + .containsEntry(CustomMappingConfigMapDependentResource.KEY, val); + }); + } + + DependentCustomMappingCustomResource testResource() { + var dr = new DependentCustomMappingCustomResource(); + dr.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + dr.setSpec(new DependentCustomMappingSpec()); + dr.getSpec().setValue(INITIAL_VALUE); + + return dr; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingCustomResource.java new file mode 100644 index 0000000000..ed07777ff3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingCustomResource.java @@ -0,0 +1,11 @@ +package io.javaoperatorsdk.operator.dependent.dependentcustommappingannotation; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class DependentCustomMappingCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingReconciler.java new file mode 100644 index 0000000000..764e98d8d5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingReconciler.java @@ -0,0 +1,19 @@ +package io.javaoperatorsdk.operator.dependent.dependentcustommappingannotation; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow(dependents = {@Dependent(type = CustomMappingConfigMapDependentResource.class)}) +@ControllerConfiguration +public class DependentCustomMappingReconciler + implements Reconciler { + + @Override + public UpdateControl reconcile( + DependentCustomMappingCustomResource resource, + Context context) + throws Exception { + + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingSpec.java new file mode 100644 index 0000000000..84b34a9491 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentcustommappingannotation/DependentCustomMappingSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.dependentcustommappingannotation; + +public class DependentCustomMappingSpec { + + private String value; + + public String getValue() { + return value; + } + + public DependentCustomMappingSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/ConfigMapDependentResource.java new file mode 100644 index 0000000000..30e0de5b7d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/ConfigMapDependentResource.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.dependent.dependentdifferentnamespace; + +import java.util.HashMap; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; + +public class ConfigMapDependentResource + extends CRUDNoGCKubernetesDependentResource< + ConfigMap, DependentDifferentNamespaceCustomResource> { + + public static final String KEY = "key"; + + public static final String NAMESPACE = "default"; + + @Override + protected ConfigMap desired( + DependentDifferentNamespaceCustomResource primary, + Context context) { + + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new ObjectMeta()); + configMap.getMetadata().setName(primary.getMetadata().getName()); + configMap.getMetadata().setNamespace(NAMESPACE); + HashMap data = new HashMap<>(); + data.put(KEY, primary.getSpec().getValue()); + configMap.setData(data); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceCustomResource.java new file mode 100644 index 0000000000..9545072809 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.dependentdifferentnamespace; + +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("ddn") +public class DependentDifferentNamespaceCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceIT.java new file mode 100644 index 0000000000..c02bac5a5d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceIT.java @@ -0,0 +1,73 @@ +package io.javaoperatorsdk.operator.dependent.dependentdifferentnamespace; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.dependent.dependentdifferentnamespace.ConfigMapDependentResource.KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class DependentDifferentNamespaceIT { + + public static final String TEST_1 = "different-ns-test1"; + public static final String INITIAL_VALUE = "initial_value"; + public static final String CHANGED_VALUE = "changed_value"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(DependentDifferentNamespaceReconciler.class) + .build(); + + @Test + void managesCRUDOperationsForDependentInDifferentNamespace() { + var resource = extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var cm = getDependentConfigMap(); + assertThat(cm).isNotNull(); + assertThat(cm.getData()).containsEntry(KEY, INITIAL_VALUE); + }); + + resource.getSpec().setValue(CHANGED_VALUE); + resource = extension.replace(resource); + + await() + .untilAsserted( + () -> { + var cm = getDependentConfigMap(); + assertThat(cm.getData()).containsEntry(KEY, CHANGED_VALUE); + }); + + extension.delete(resource); + await() + .untilAsserted( + () -> { + var cm = getDependentConfigMap(); + assertThat(cm).isNull(); + }); + } + + private ConfigMap getDependentConfigMap() { + return extension + .getKubernetesClient() + .configMaps() + .inNamespace(ConfigMapDependentResource.NAMESPACE) + .withName(TEST_1) + .get(); + } + + DependentDifferentNamespaceCustomResource testResource() { + var res = new DependentDifferentNamespaceCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_1).build()); + res.setSpec(new DependentDifferentNamespaceSpec()); + res.getSpec().setValue(INITIAL_VALUE); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceReconciler.java new file mode 100644 index 0000000000..3d2b71338c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceReconciler.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.dependent.dependentdifferentnamespace; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow( + dependents = { + @Dependent(type = ConfigMapDependentResource.class), + }) +@ControllerConfiguration +public class DependentDifferentNamespaceReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + DependentDifferentNamespaceCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceSpec.java new file mode 100644 index 0000000000..4c6961fda6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentdifferentnamespace/DependentDifferentNamespaceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.dependentdifferentnamespace; + +public class DependentDifferentNamespaceSpec { + + private String value; + + public String getValue() { + return value; + } + + public DependentDifferentNamespaceSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterIT.java new file mode 100644 index 0000000000..bc7b578d7f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterIT.java @@ -0,0 +1,67 @@ +package io.javaoperatorsdk.operator.dependent.dependentfilter; + +import java.time.Duration; +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.dependent.dependentfilter.DependentFilterTestReconciler.CM_VALUE_KEY; +import static io.javaoperatorsdk.operator.dependent.dependentfilter.DependentFilterTestReconciler.CONFIG_MAP_FILTER_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class DependentFilterIT { + + public static final String RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(DependentFilterTestReconciler.class) + .build(); + + @Test + void filtersUpdateOnConfigMap() { + var resource = createResource(); + operator.create(resource); + + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(DependentFilterTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(1); + }); + + var configMap = operator.get(ConfigMap.class, RESOURCE_NAME); + configMap.setData(Map.of(CM_VALUE_KEY, CONFIG_MAP_FILTER_VALUE)); + operator.replace(configMap); + + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(DependentFilterTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(1); + }); + } + + DependentFilterTestCustomResource createResource() { + DependentFilterTestCustomResource resource = new DependentFilterTestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + resource.setSpec(new DependentFilterTestResourceSpec()); + resource.getSpec().setValue("value1"); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterTestCustomResource.java new file mode 100644 index 0000000000..bc4d92edbb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterTestCustomResource.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.dependent.dependentfilter; + +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("dft") +public class DependentFilterTestCustomResource + extends CustomResource implements Namespaced { + + public String getConfigMapName(int id) { + return "configmap" + id; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterTestReconciler.java new file mode 100644 index 0000000000..2351512046 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterTestReconciler.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.dependent.dependentfilter; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow(dependents = {@Dependent(type = FilteredDependentConfigMap.class)}) +@ControllerConfiguration(informer = @Informer(onUpdateFilter = UpdateFilter.class)) +public class DependentFilterTestReconciler + implements Reconciler { + + public static final String CONFIG_MAP_FILTER_VALUE = "config_map_skip_this"; + public static final String CM_VALUE_KEY = "value"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + DependentFilterTestCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterTestResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterTestResourceSpec.java new file mode 100644 index 0000000000..a09d68b503 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/DependentFilterTestResourceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.dependentfilter; + +public class DependentFilterTestResourceSpec { + + private String value; + + public String getValue() { + return value; + } + + public DependentFilterTestResourceSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/FilteredDependentConfigMap.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/FilteredDependentConfigMap.java new file mode 100644 index 0000000000..3b12673b4c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/FilteredDependentConfigMap.java @@ -0,0 +1,31 @@ +package io.javaoperatorsdk.operator.dependent.dependentfilter; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +import static io.javaoperatorsdk.operator.dependent.dependentfilter.DependentFilterTestReconciler.CM_VALUE_KEY; + +@KubernetesDependent(informer = @Informer(onUpdateFilter = UpdateFilter.class)) +public class FilteredDependentConfigMap + extends CRUDKubernetesDependentResource { + + @Override + protected ConfigMap desired( + DependentFilterTestCustomResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of(CM_VALUE_KEY, primary.getSpec().getValue())); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/UpdateFilter.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/UpdateFilter.java new file mode 100644 index 0000000000..999b3f00d3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentfilter/UpdateFilter.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.dependent.dependentfilter; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; + +import static io.javaoperatorsdk.operator.dependent.dependentfilter.DependentFilterTestReconciler.CM_VALUE_KEY; +import static io.javaoperatorsdk.operator.dependent.dependentfilter.DependentFilterTestReconciler.CONFIG_MAP_FILTER_VALUE; + +public class UpdateFilter implements OnUpdateFilter { + @Override + public boolean accept(ConfigMap resource, ConfigMap oldResource) { + return !resource.getData().get(CM_VALUE_KEY).equals(CONFIG_MAP_FILTER_VALUE); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/ConfigMapDependentResource.java new file mode 100644 index 0000000000..87b827c527 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/ConfigMapDependentResource.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.dependent.dependentoperationeventfiltering; + +import java.util.HashMap; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class ConfigMapDependentResource + extends CRUDKubernetesDependentResource< + ConfigMap, DependentOperationEventFilterCustomResource> { + + public static final String KEY = "key1"; + + @Override + protected ConfigMap desired( + DependentOperationEventFilterCustomResource primary, + Context context) { + + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new ObjectMeta()); + configMap.getMetadata().setName(primary.getMetadata().getName()); + configMap.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + HashMap data = new HashMap<>(); + data.put(KEY, primary.getSpec().getValue()); + configMap.setData(data); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterCustomResource.java new file mode 100644 index 0000000000..67ab4c0937 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterCustomResource.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.dependent.dependentoperationeventfiltering; + +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("OperationEventFilterCustomResource") +@ShortNames("oef") +public class DependentOperationEventFilterCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterCustomResourceSpec.java new file mode 100644 index 0000000000..ba131f75b0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterCustomResourceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.dependentoperationeventfiltering; + +public class DependentOperationEventFilterCustomResourceSpec { + + private String value; + + public String getValue() { + return value; + } + + public DependentOperationEventFilterCustomResourceSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterCustomResourceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterCustomResourceTestReconciler.java new file mode 100644 index 0000000000..d787b736d1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterCustomResourceTestReconciler.java @@ -0,0 +1,28 @@ +package io.javaoperatorsdk.operator.dependent.dependentoperationeventfiltering; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow(dependents = {@Dependent(type = ConfigMapDependentResource.class)}) +@ControllerConfiguration(informer = @Informer(namespaces = Constants.WATCH_CURRENT_NAMESPACE)) +public class DependentOperationEventFilterCustomResourceTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + DependentOperationEventFilterCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterIT.java new file mode 100644 index 0000000000..e9ddc6cd6e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentoperationeventfiltering/DependentOperationEventFilterIT.java @@ -0,0 +1,69 @@ +package io.javaoperatorsdk.operator.dependent.dependentoperationeventfiltering; + +import java.time.Duration; + +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.ObjectMeta; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class DependentOperationEventFilterIT { + + public static final String TEST = "test"; + public static final String SPEC_VAL_1 = "val1"; + public static final String SPEC_VAL_2 = "val2"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withNamespaceDeleteTimeout(2) + .withReconciler(new DependentOperationEventFilterCustomResourceTestReconciler()) + .build(); + + @Test + void reconcileNotTriggeredWithDependentResourceCreateOrUpdate() { + var resource = operator.create(createTestResource()); + + await() + .pollDelay(Duration.ofSeconds(1)) + .atMost(Duration.ofSeconds(3)) + .until( + () -> + ((DependentOperationEventFilterCustomResourceTestReconciler) + operator.getFirstReconciler()) + .getNumberOfExecutions() + == 1); + assertThat(operator.get(ConfigMap.class, TEST).getData()) + .containsEntry(ConfigMapDependentResource.KEY, SPEC_VAL_1); + + resource.getSpec().setValue(SPEC_VAL_2); + operator.replace(resource); + + await() + .pollDelay(Duration.ofSeconds(1)) + .atMost(Duration.ofSeconds(3)) + .until( + () -> + ((DependentOperationEventFilterCustomResourceTestReconciler) + operator.getFirstReconciler()) + .getNumberOfExecutions() + == 2); + assertThat(operator.get(ConfigMap.class, TEST).getData()) + .containsEntry(ConfigMapDependentResource.KEY, SPEC_VAL_2); + } + + private DependentOperationEventFilterCustomResource createTestResource() { + DependentOperationEventFilterCustomResource cr = + new DependentOperationEventFilterCustomResource(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName(TEST); + cr.setSpec(new DependentOperationEventFilterCustomResourceSpec()); + cr.getSpec().setValue(SPEC_VAL_1); + return cr; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/ConfigMapDependentResource.java new file mode 100644 index 0000000000..2a245a3721 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/ConfigMapDependentResource.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.dependent.dependentreinitialization; + +import java.util.Map; + +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.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class ConfigMapDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected ConfigMap desired( + DependentReInitializationCustomResource primary, + Context context) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of("key", "val")) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/DependentReInitializationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/DependentReInitializationCustomResource.java new file mode 100644 index 0000000000..59991ddda2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/DependentReInitializationCustomResource.java @@ -0,0 +1,11 @@ +package io.javaoperatorsdk.operator.dependent.dependentreinitialization; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class DependentReInitializationCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/DependentReInitializationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/DependentReInitializationIT.java new file mode 100644 index 0000000000..270b89a6a5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/DependentReInitializationIT.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.dependent.dependentreinitialization; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +class DependentReInitializationIT { + + /** + * In case dependent resource is managed by CDI (like in Quarkus) can be handy that the instance + * is reused in tests. + */ + @Test + void dependentCanDeReInitialized() { + var client = new KubernetesClientBuilder().build(); + LocallyRunOperatorExtension.applyCrd(DependentReInitializationCustomResource.class, client); + + var dependent = new ConfigMapDependentResource(); + + startEndStopOperator(client, dependent); + startEndStopOperator(client, dependent); + } + + private static void startEndStopOperator( + KubernetesClient client, ConfigMapDependentResource dependent) { + Operator o1 = new Operator(o -> o.withCloseClientOnStop(false).withKubernetesClient(client)); + o1.register(new DependentReInitializationReconciler(dependent)); + o1.start(); + o1.stop(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/DependentReInitializationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/DependentReInitializationReconciler.java new file mode 100644 index 0000000000..8c435e5cc8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentreinitialization/DependentReInitializationReconciler.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.dependent.dependentreinitialization; + +import java.util.List; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; + +@ControllerConfiguration +public class DependentReInitializationReconciler + implements Reconciler { + + private final ConfigMapDependentResource configMapDependentResource; + + public DependentReInitializationReconciler(ConfigMapDependentResource dependentResource) { + this.configMapDependentResource = dependentResource; + } + + @Override + public UpdateControl reconcile( + DependentReInitializationCustomResource resource, + Context context) + throws Exception { + configMapDependentResource.reconcile(resource, context); + return UpdateControl.noUpdate(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + return EventSourceUtils.dependentEventSources(context, configMapDependentResource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentresourcecrossref/DependentResourceCrossRefIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentresourcecrossref/DependentResourceCrossRefIT.java new file mode 100644 index 0000000000..1b71c79448 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentresourcecrossref/DependentResourceCrossRefIT.java @@ -0,0 +1,70 @@ +package io.javaoperatorsdk.operator.dependent.dependentresourcecrossref; + +import java.time.Duration; + +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.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class DependentResourceCrossRefIT { + + public static final String TEST_RESOURCE_NAME = "test"; + public static final int EXECUTION_NUMBER = 50; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new DependentResourceCrossRefReconciler()) + .build(); + + @Test + void dependentResourceCanReferenceEachOther() { + + for (int i = 0; i < EXECUTION_NUMBER; i++) { + operator.create(testResource(i)); + } + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(DependentResourceCrossRefReconciler.class) + .isErrorHappened()) + .isFalse(); + for (int i = 0; i < EXECUTION_NUMBER; i++) { + assertThat(operator.get(ConfigMap.class, TEST_RESOURCE_NAME + i)).isNotNull(); + assertThat(operator.get(Secret.class, TEST_RESOURCE_NAME + i)).isNotNull(); + } + }); + + for (int i = 0; i < EXECUTION_NUMBER; i++) { + operator.delete(testResource(i)); + } + await() + .timeout(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + for (int i = 0; i < EXECUTION_NUMBER; i++) { + assertThat( + operator.get( + DependentResourceCrossRefResource.class, + testResource(i).getMetadata().getName())) + .isNull(); + } + }); + } + + DependentResourceCrossRefResource testResource(int n) { + var res = new DependentResourceCrossRefResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME + n).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentresourcecrossref/DependentResourceCrossRefReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentresourcecrossref/DependentResourceCrossRefReconciler.java new file mode 100644 index 0000000000..247174838c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentresourcecrossref/DependentResourceCrossRefReconciler.java @@ -0,0 +1,105 @@ +package io.javaoperatorsdk.operator.dependent.dependentresourcecrossref; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +import static io.javaoperatorsdk.operator.dependent.dependentresourcecrossref.DependentResourceCrossRefReconciler.SECRET_NAME; + +@Workflow( + dependents = { + @Dependent( + name = SECRET_NAME, + type = DependentResourceCrossRefReconciler.SecretDependentResource.class), + @Dependent( + type = DependentResourceCrossRefReconciler.ConfigMapDependentResource.class, + dependsOn = SECRET_NAME) + }) +@ControllerConfiguration +public class DependentResourceCrossRefReconciler + implements Reconciler { + private static final Logger log = + LoggerFactory.getLogger(DependentResourceCrossRefReconciler.class); + + public static final String SECRET_NAME = "secret"; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private volatile boolean errorHappened = false; + + @Override + public UpdateControl reconcile( + DependentResourceCrossRefResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + DependentResourceCrossRefResource resource, + Context context, + Exception e) { + log.error("Status update on error", e); + errorHappened = true; + return ErrorStatusUpdateControl.noStatusUpdate(); + } + + public boolean isErrorHappened() { + return errorHappened; + } + + public static class SecretDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected Secret desired( + DependentResourceCrossRefResource primary, + Context context) { + Secret secret = new Secret(); + secret.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + secret.setData(Map.of("key", Base64.getEncoder().encodeToString("secretData".getBytes()))); + return secret; + } + } + + public static class ConfigMapDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected ConfigMap desired( + DependentResourceCrossRefResource primary, + Context context) { + var secret = context.getSecondaryResource(Secret.class); + if (secret.isEmpty()) { + throw new IllegalStateException("Secret is empty"); + } + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData( + Map.of("secretKey", new ArrayList<>(secret.get().getData().keySet()).get(0))); + return configMap; + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentresourcecrossref/DependentResourceCrossRefResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentresourcecrossref/DependentResourceCrossRefResource.java new file mode 100644 index 0000000000..3e4abcc850 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentresourcecrossref/DependentResourceCrossRefResource.java @@ -0,0 +1,11 @@ +package io.javaoperatorsdk.operator.dependent.dependentresourcecrossref; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class DependentResourceCrossRefResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSACustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSACustomResource.java new file mode 100644 index 0000000000..4c3d79917f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSACustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.dependentssa; + +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("dssa") +public class DependentSSACustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSAMatchingIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSAMatchingIT.java new file mode 100644 index 0000000000..8ab686b1b8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSAMatchingIT.java @@ -0,0 +1,107 @@ +package io.javaoperatorsdk.operator.dependent.dependentssa; + +import java.time.Duration; +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.fabric8.kubernetes.client.dsl.base.PatchContext; +import io.fabric8.kubernetes.client.dsl.base.PatchType; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class DependentSSAMatchingIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String INITIAL_VALUE = "INITIAL_VALUE"; + public static final String CHANGED_VALUE = "CHANGED_VALUE"; + + public static final String CUSTOM_FIELD_MANAGER_NAME = "customFieldManagerName"; + public static final String OTHER_FIELD_MANAGER = "otherFieldManager"; + public static final String ADDITIONAL_KEY = "key2"; + public static final String ADDITIONAL_VALUE = "Additional Value"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler( + new DependentSSAReconciler(), o -> o.withFieldManager(CUSTOM_FIELD_MANAGER_NAME)) + .build(); + + @Test + void testMatchingAndUpdate() { + SSAConfigMapDependent.NUMBER_OF_UPDATES.set(0); + var resource = extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(cm).isNotNull(); + assertThat(cm.getData()).containsEntry(SSAConfigMapDependent.DATA_KEY, INITIAL_VALUE); + assertThat( + cm.getMetadata().getManagedFields().stream() + .filter(fm -> fm.getManager().equals(CUSTOM_FIELD_MANAGER_NAME))) + .isNotEmpty(); + assertThat(SSAConfigMapDependent.NUMBER_OF_UPDATES.get()).isZero(); + }); + + ConfigMap cmPatch = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(TEST_RESOURCE_NAME) + .withNamespace(resource.getMetadata().getNamespace()) + .build()) + .withData(Map.of(ADDITIONAL_KEY, ADDITIONAL_VALUE)) + .build(); + + extension + .getKubernetesClient() + .configMaps() + .resource(cmPatch) + .patch( + new PatchContext.Builder() + .withFieldManager(OTHER_FIELD_MANAGER) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build()); + + await() + .pollDelay(Duration.ofMillis(300)) + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(cm.getData()).hasSize(2); + assertThat(SSAConfigMapDependent.NUMBER_OF_UPDATES.get()).isZero(); + assertThat(cm.getMetadata().getManagedFields()).hasSize(2); + }); + + resource.getSpec().setValue(CHANGED_VALUE); + extension.replace(resource); + + await() + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(cm.getData()).hasSize(2); + assertThat(cm.getData()).containsEntry(SSAConfigMapDependent.DATA_KEY, CHANGED_VALUE); + assertThat(cm.getData()).containsEntry(ADDITIONAL_KEY, ADDITIONAL_VALUE); + assertThat(cm.getMetadata().getManagedFields()).hasSize(2); + assertThat(SSAConfigMapDependent.NUMBER_OF_UPDATES.get()).isEqualTo(1); + }); + } + + public DependentSSACustomResource testResource() { + DependentSSACustomResource resource = new DependentSSACustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + resource.setSpec(new DependentSSASpec()); + resource.getSpec().setValue(INITIAL_VALUE); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSAMigrationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSAMigrationIT.java new file mode 100644 index 0000000000..0d354febdf --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSAMigrationIT.java @@ -0,0 +1,184 @@ +package io.javaoperatorsdk.operator.dependent.dependentssa; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.NamespaceBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.fabric8.kubernetes.client.utils.KubernetesResourceUtil; +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class DependentSSAMigrationIT { + + public static final String FABRIC8_CLIENT_DEFAULT_FIELD_MANAGER = "fabric8-kubernetes-client"; + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String INITIAL_VALUE = "INITIAL_VALUE"; + public static final String CHANGED_VALUE = "CHANGED_VALUE"; + + private String namespace; + private final KubernetesClient client = new KubernetesClientBuilder().build(); + + @BeforeEach + void setup(TestInfo testInfo) { + SSAConfigMapDependent.NUMBER_OF_UPDATES.set(0); + LocallyRunOperatorExtension.applyCrd(DependentSSACustomResource.class, client); + testInfo + .getTestMethod() + .ifPresent( + method -> { + namespace = KubernetesResourceUtil.sanitizeName(method.getName()); + cleanup(); + client + .namespaces() + .resource( + new NamespaceBuilder() + .withMetadata(new ObjectMetaBuilder().withName(namespace).build()) + .build()) + .create(); + }); + } + + @AfterEach + void cleanup() { + client + .namespaces() + .resource( + new NamespaceBuilder() + .withMetadata(new ObjectMetaBuilder().withName(namespace).build()) + .build()) + .delete(); + } + + @Test + void migratesFromLegacyToWorksAndBack() { + var legacyOperator = createOperator(client, true, null); + DependentSSACustomResource testResource = reconcileWithLegacyOperator(legacyOperator); + + var operator = createOperator(client, false, null); + testResource = reconcileWithNewApproach(testResource, operator); + var cm = getDependentConfigMap(); + assertThat(cm.getMetadata().getManagedFields()).hasSize(2); + + reconcileAgainWithLegacy(legacyOperator, testResource); + } + + @Test + void usingDefaultFieldManagerDoesNotCreatesANewOneWithApplyOperation() { + var legacyOperator = createOperator(client, true, null); + DependentSSACustomResource testResource = reconcileWithLegacyOperator(legacyOperator); + + var operator = createOperator(client, false, FABRIC8_CLIENT_DEFAULT_FIELD_MANAGER); + reconcileWithNewApproach(testResource, operator); + + var cm = getDependentConfigMap(); + + assertThat(cm.getMetadata().getManagedFields()).hasSize(2); + assertThat(cm.getMetadata().getManagedFields()) + // Jetty seems to be a bug in fabric8 client, it is only the default fieldManager if Jetty + // is used as http client + .allMatch( + fm -> + fm.getManager().equals(FABRIC8_CLIENT_DEFAULT_FIELD_MANAGER) + || fm.getManager().equals("Jetty")); + } + + private void reconcileAgainWithLegacy( + Operator legacyOperator, DependentSSACustomResource testResource) { + legacyOperator.start(); + + testResource.getSpec().setValue(INITIAL_VALUE); + testResource.getMetadata().setResourceVersion(null); + client.resource(testResource).update(); + + await() + .untilAsserted( + () -> { + var cm = getDependentConfigMap(); + assertThat(cm.getData()).containsEntry(SSAConfigMapDependent.DATA_KEY, INITIAL_VALUE); + }); + + legacyOperator.stop(); + } + + private DependentSSACustomResource reconcileWithNewApproach( + DependentSSACustomResource testResource, Operator operator) { + operator.start(); + + await() + .untilAsserted( + () -> { + var cm = getDependentConfigMap(); + assertThat(cm).isNotNull(); + assertThat(cm.getData()).hasSize(1); + }); + + testResource.getSpec().setValue(CHANGED_VALUE); + testResource.getMetadata().setResourceVersion(null); + testResource = client.resource(testResource).update(); + + await() + .untilAsserted( + () -> { + var cm = getDependentConfigMap(); + assertThat(cm.getData()).containsEntry(SSAConfigMapDependent.DATA_KEY, CHANGED_VALUE); + }); + operator.stop(); + return testResource; + } + + private ConfigMap getDependentConfigMap() { + return client.configMaps().inNamespace(namespace).withName(TEST_RESOURCE_NAME).get(); + } + + private DependentSSACustomResource reconcileWithLegacyOperator(Operator legacyOperator) { + legacyOperator.start(); + + var testResource = client.resource(testResource()).create(); + + await() + .untilAsserted( + () -> { + var cm = getDependentConfigMap(); + assertThat(cm).isNotNull(); + assertThat(cm.getMetadata().getManagedFields()).hasSize(1); + assertThat(cm.getData()).hasSize(1); + }); + + legacyOperator.stop(); + return testResource; + } + + private Operator createOperator( + KubernetesClient client, boolean legacyDependentHandling, String fieldManager) { + Operator operator = + new Operator(o -> o.withKubernetesClient(client).withCloseClientOnStop(false)); + var reconciler = new DependentSSAReconciler(!legacyDependentHandling); + operator.register( + reconciler, + o -> { + o.settingNamespace(namespace); + if (fieldManager != null) { + o.withFieldManager(fieldManager); + } + }); + return operator; + } + + public DependentSSACustomResource testResource() { + DependentSSACustomResource resource = new DependentSSACustomResource(); + resource.setMetadata( + new ObjectMetaBuilder().withNamespace(namespace).withName(TEST_RESOURCE_NAME).build()); + resource.setSpec(new DependentSSASpec()); + resource.getSpec().setValue(INITIAL_VALUE); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSAReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSAReconciler.java new file mode 100644 index 0000000000..596f7e9991 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSAReconciler.java @@ -0,0 +1,62 @@ +package io.javaoperatorsdk.operator.dependent.dependentssa; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceUtils; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfigBuilder; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration +public class DependentSSAReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + private final SSAConfigMapDependent ssaConfigMapDependent = new SSAConfigMapDependent(); + private final boolean useSSA; + + public DependentSSAReconciler() { + this(true); + } + + public DependentSSAReconciler(boolean useSSA) { + ssaConfigMapDependent.configureWith( + new KubernetesDependentResourceConfigBuilder().withUseSSA(useSSA).build()); + this.useSSA = useSSA; + } + + public boolean isUseSSA() { + return useSSA; + } + + public SSAConfigMapDependent getSsaConfigMapDependent() { + return ssaConfigMapDependent; + } + + @Override + public UpdateControl reconcile( + DependentSSACustomResource resource, Context context) { + + ssaConfigMapDependent.reconcile(resource, context); + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + return EventSourceUtils.dependentEventSources(context, ssaConfigMapDependent); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSASpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSASpec.java new file mode 100644 index 0000000000..df4684dce5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/DependentSSASpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.dependentssa; + +public class DependentSSASpec { + + private String value; + + public String getValue() { + return value; + } + + public DependentSSASpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/SSAConfigMapDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/SSAConfigMapDependent.java new file mode 100644 index 0000000000..49d2c1de44 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/dependentssa/SSAConfigMapDependent.java @@ -0,0 +1,41 @@ +package io.javaoperatorsdk.operator.dependent.dependentssa; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +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.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class SSAConfigMapDependent + extends CRUDKubernetesDependentResource { + + public static AtomicInteger NUMBER_OF_UPDATES = new AtomicInteger(0); + + public static final String DATA_KEY = "key1"; + + @Override + protected ConfigMap desired( + DependentSSACustomResource primary, Context context) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of(DATA_KEY, primary.getSpec().getValue())) + .build(); + } + + @Override + public ConfigMap update( + ConfigMap actual, + ConfigMap desired, + DependentSSACustomResource primary, + Context context) { + NUMBER_OF_UPDATES.incrementAndGet(); + return super.update(actual, desired, primary, context); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateCustomResource.java new file mode 100644 index 0000000000..21c3c0b4ea --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.externalstate; + +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("ess") +public class ExternalStateCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateDependentIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateDependentIT.java new file mode 100644 index 0000000000..87e588673d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateDependentIT.java @@ -0,0 +1,19 @@ +package io.javaoperatorsdk.operator.dependent.externalstate; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +public class ExternalStateDependentIT extends ExternalStateTestBase { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(ExternalStateDependentReconciler.class) + .build(); + + @Override + public LocallyRunOperatorExtension extension() { + return operator; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateDependentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateDependentReconciler.java new file mode 100644 index 0000000000..5417851271 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateDependentReconciler.java @@ -0,0 +1,45 @@ +package io.javaoperatorsdk.operator.dependent.externalstate; + +import java.util.List; +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.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow(dependents = @Dependent(type = ExternalWithStateDependentResource.class)) +@ControllerConfiguration +public class ExternalStateDependentReconciler + implements Reconciler, TestExecutionInfoProvider { + + public static final String ID_KEY = "id"; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + ExternalStateCustomResource resource, Context context) { + numberOfExecutions.addAndGet(1); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + var configMapEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, ExternalStateCustomResource.class) + .build(), + context); + return List.of(configMapEventSource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateIT.java new file mode 100644 index 0000000000..bae36431b5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateIT.java @@ -0,0 +1,75 @@ +package io.javaoperatorsdk.operator.dependent.externalstate; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; + +import static io.javaoperatorsdk.operator.dependent.externalstate.ExternalStateReconciler.ID_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class ExternalStateIT { + + private static final String TEST_RESOURCE_NAME = "test1"; + + public static final String INITIAL_TEST_DATA = "initialTestData"; + public static final String UPDATED_DATA = "updatedData"; + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(ExternalStateReconciler.class).build(); + + @Test + public void reconcilesResourceWithPersistentState() { + var resource = operator.create(testResource()); + assertResourcesCreated(resource, INITIAL_TEST_DATA); + + resource.getSpec().setData(UPDATED_DATA); + operator.replace(resource); + assertResourcesCreated(resource, UPDATED_DATA); + + operator.delete(resource); + assertResourcesDeleted(resource); + } + + private void assertResourcesDeleted(ExternalStateCustomResource resource) { + await() + .untilAsserted( + () -> { + var cm = operator.get(ConfigMap.class, resource.getMetadata().getName()); + var resources = externalService.listResources(); + assertThat(cm).isNull(); + assertThat(resources).isEmpty(); + }); + } + + private void assertResourcesCreated( + ExternalStateCustomResource resource, String initialTestData) { + await() + .untilAsserted( + () -> { + var cm = operator.get(ConfigMap.class, resource.getMetadata().getName()); + var resources = externalService.listResources(); + assertThat(resources).hasSize(1); + var extRes = externalService.listResources().get(0); + assertThat(extRes.getData()).isEqualTo(initialTestData); + assertThat(cm).isNotNull(); + assertThat(cm.getData().get(ID_KEY)).isEqualTo(extRes.getId()); + }); + } + + private ExternalStateCustomResource testResource() { + var res = new ExternalStateCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + + res.setSpec(new ExternalStateSpec()); + res.getSpec().setData(INITIAL_TEST_DATA); + return res; + } +} 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 new file mode 100644 index 0000000000..7b741102d7 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateReconciler.java @@ -0,0 +1,153 @@ +package io.javaoperatorsdk.operator.dependent.externalstate; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +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.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +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.EventSourceStartPriority; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingConfigurationBuilder; +import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingEventSource; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; +import io.javaoperatorsdk.operator.support.ExternalResource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration +public class ExternalStateReconciler + implements Reconciler, + Cleaner, + TestExecutionInfoProvider { + + public static final String ID_KEY = "id"; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + InformerEventSource configMapEventSource; + PerResourcePollingEventSource + externalResourceEventSource; + + @Override + public UpdateControl reconcile( + ExternalStateCustomResource resource, Context context) { + numberOfExecutions.addAndGet(1); + + var externalResource = context.getSecondaryResource(ExternalResource.class); + externalResource.ifPresentOrElse( + r -> { + if (!r.getData().equals(resource.getSpec().getData())) { + updateExternalResource(resource, r, context); + } + }, + () -> { + if (externalResource.isEmpty()) { + createExternalResource(resource, context); + } + }); + + return UpdateControl.noUpdate(); + } + + private void updateExternalResource( + ExternalStateCustomResource resource, + ExternalResource externalResource, + Context context) { + var newResource = new ExternalResource(externalResource.getId(), resource.getSpec().getData()); + externalService.update(newResource); + externalResourceEventSource.handleRecentResourceUpdate( + ResourceID.fromResource(resource), newResource, externalResource); + } + + private void createExternalResource( + ExternalStateCustomResource resource, Context context) { + var createdResource = + externalService.create(new ExternalResource(resource.getSpec().getData())); + var configMap = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .build()) + .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); + externalResourceEventSource.handleRecentResourceCreate(primaryID, createdResource); + } + + @Override + public DeleteControl cleanup( + ExternalStateCustomResource resource, Context context) { + var externalResource = context.getSecondaryResource(ExternalResource.class); + externalResource.ifPresent(er -> externalService.delete(er.getId())); + context + .getClient() + .configMaps() + .inNamespace(resource.getMetadata().getNamespace()) + .withName(resource.getMetadata().getName()) + .delete(); + return DeleteControl.defaultDelete(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + configMapEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, ExternalStateCustomResource.class) + .build(), + context); + configMapEventSource.setEventSourcePriority(EventSourceStartPriority.RESOURCE_STATE_LOADER); + + final PerResourcePollingEventSource.ResourceFetcher< + ExternalResource, ExternalStateCustomResource> + fetcher = + (ExternalStateCustomResource primaryResource) -> { + var configMap = + configMapEventSource.getSecondaryResource(primaryResource).orElse(null); + if (configMap == null) { + return Collections.emptySet(); + } + var id = configMap.getData().get(ID_KEY); + var externalResource = externalService.read(id); + return externalResource.map(Set::of).orElseGet(Collections::emptySet); + }; + externalResourceEventSource = + new PerResourcePollingEventSource<>( + ExternalResource.class, + context, + new PerResourcePollingConfigurationBuilder<>(fetcher, Duration.ofMillis(300L)).build()); + + return Arrays.asList(configMapEventSource, externalResourceEventSource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateSpec.java new file mode 100644 index 0000000000..d073fcd1f8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.externalstate; + +public class ExternalStateSpec { + + private String data; + + public String getData() { + return data; + } + + public ExternalStateSpec setData(String data) { + this.data = data; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateTestBase.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateTestBase.java new file mode 100644 index 0000000000..31aea2d6c7 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalStateTestBase.java @@ -0,0 +1,72 @@ +package io.javaoperatorsdk.operator.dependent.externalstate; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; + +import static io.javaoperatorsdk.operator.dependent.externalstate.ExternalStateReconciler.ID_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public abstract class ExternalStateTestBase { + + private static final String TEST_RESOURCE_NAME = "test1"; + + public static final String INITIAL_TEST_DATA = "initialTestData"; + public static final String UPDATED_DATA = "updatedData"; + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + @Test + public void reconcilesResourceWithPersistentState() { + var resource = extension().create(testResource()); + assertResourcesCreated(resource, INITIAL_TEST_DATA); + + resource.getSpec().setData(UPDATED_DATA); + extension().replace(resource); + assertResourcesCreated(resource, UPDATED_DATA); + + extension().delete(resource); + assertResourcesDeleted(resource); + } + + private void assertResourcesDeleted(ExternalStateCustomResource resource) { + await() + .untilAsserted( + () -> { + var cm = extension().get(ConfigMap.class, resource.getMetadata().getName()); + var resources = externalService.listResources(); + assertThat(cm).isNull(); + assertThat(resources).isEmpty(); + }); + } + + private void assertResourcesCreated( + ExternalStateCustomResource resource, String initialTestData) { + await() + .untilAsserted( + () -> { + var cm = extension().get(ConfigMap.class, resource.getMetadata().getName()); + var resources = externalService.listResources(); + assertThat(resources).hasSize(1); + var extRes = externalService.listResources().get(0); + assertThat(extRes.getData()).isEqualTo(initialTestData); + assertThat(cm).isNotNull(); + assertThat(cm.getData().get(ID_KEY)).isEqualTo(extRes.getId()); + }); + } + + private ExternalStateCustomResource testResource() { + var res = new ExternalStateCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + + res.setSpec(new ExternalStateSpec()); + res.getSpec().setData(INITIAL_TEST_DATA); + return res; + } + + abstract LocallyRunOperatorExtension extension(); +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalWithStateDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalWithStateDependentResource.java new file mode 100644 index 0000000000..5a1fa6efb3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/ExternalWithStateDependentResource.java @@ -0,0 +1,117 @@ +package io.javaoperatorsdk.operator.dependent.externalstate; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +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.processing.dependent.DependentResourceWithExplicitState; +import io.javaoperatorsdk.operator.processing.dependent.Matcher; +import io.javaoperatorsdk.operator.processing.dependent.Updater; +import io.javaoperatorsdk.operator.processing.dependent.external.PerResourcePollingDependentResource; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; +import io.javaoperatorsdk.operator.support.ExternalResource; + +public class ExternalWithStateDependentResource + extends PerResourcePollingDependentResource + implements DependentResourceWithExplicitState< + ExternalResource, ExternalStateCustomResource, ConfigMap>, + Updater { + + ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + public ExternalWithStateDependentResource() { + super(ExternalResource.class, Duration.ofMillis(300)); + } + + @Override + @SuppressWarnings("unchecked") + public Set fetchResources(ExternalStateCustomResource primaryResource) { + return getResourceID(primaryResource) + .map( + id -> { + var externalResource = externalService.read(id); + return externalResource.map(Set::of).orElseGet(Collections::emptySet); + }) + .orElseGet(Collections::emptySet); + } + + @Override + protected Optional selectTargetSecondaryResource( + Set secondaryResources, + ExternalStateCustomResource primary, + Context context) { + var id = getResourceID(primary); + return id.flatMap(k -> secondaryResources.stream().filter(e -> e.getId().equals(k)).findAny()); + } + + private Optional getResourceID(ExternalStateCustomResource primaryResource) { + Optional configMapOptional = + getExternalStateEventSource().getSecondaryResource(primaryResource); + return configMapOptional.map(cm -> cm.getData().get(ExternalStateDependentReconciler.ID_KEY)); + } + + @Override + protected ExternalResource desired( + ExternalStateCustomResource primary, Context context) { + return new ExternalResource(primary.getSpec().getData()); + } + + @Override + public Class stateResourceClass() { + return ConfigMap.class; + } + + @Override + public ConfigMap stateResource(ExternalStateCustomResource primary, ExternalResource resource) { + ConfigMap configMap = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of(ExternalStateDependentReconciler.ID_KEY, resource.getId())) + .build(); + configMap.addOwnerReference(primary); + return configMap; + } + + @Override + public ExternalResource create( + ExternalResource desired, + ExternalStateCustomResource primary, + Context context) { + return externalService.create(desired); + } + + @Override + public ExternalResource update( + ExternalResource actual, + ExternalResource desired, + ExternalStateCustomResource primary, + Context context) { + return externalService.update(new ExternalResource(actual.getId(), desired.getData())); + } + + @Override + public Matcher.Result match( + ExternalResource resource, + ExternalStateCustomResource primary, + Context context) { + return Matcher.Result.nonComputed(resource.getData().equals(primary.getSpec().getData())); + } + + @Override + protected void handleDelete( + ExternalStateCustomResource primary, + ExternalResource secondary, + Context context) { + externalService.delete(secondary.getId()); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/BulkDependentResourceExternalWithState.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/BulkDependentResourceExternalWithState.java new file mode 100644 index 0000000000..ac3ddb3778 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/BulkDependentResourceExternalWithState.java @@ -0,0 +1,152 @@ +package io.javaoperatorsdk.operator.dependent.externalstate.externalstatebulkdependent; + +import java.time.Duration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +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.dependent.externalstate.ExternalStateDependentReconciler; +import io.javaoperatorsdk.operator.processing.dependent.*; +import io.javaoperatorsdk.operator.processing.dependent.external.PerResourcePollingDependentResource; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; +import io.javaoperatorsdk.operator.support.ExternalResource; + +public class BulkDependentResourceExternalWithState + extends PerResourcePollingDependentResource< + ExternalResource, ExternalStateBulkDependentCustomResource> + implements BulkDependentResource, + CRUDBulkDependentResource, + DependentResourceWithExplicitState< + ExternalResource, ExternalStateBulkDependentCustomResource, ConfigMap> { + + public static final String DELIMITER = "-"; + ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + public BulkDependentResourceExternalWithState() { + super(ExternalResource.class, Duration.ofMillis(300)); + } + + @Override + @SuppressWarnings("unchecked") + public Set fetchResources( + ExternalStateBulkDependentCustomResource primaryResource) { + Set configMaps = + getExternalStateEventSource().getSecondaryResources(primaryResource); + Set res = new HashSet<>(); + + configMaps.forEach( + cm -> { + var id = cm.getData().get(ExternalStateDependentReconciler.ID_KEY); + var externalResource = externalService.read(id); + externalResource.ifPresent(res::add); + }); + return res; + } + + @Override + public Class stateResourceClass() { + return ConfigMap.class; + } + + @Override + public ConfigMap stateResource( + ExternalStateBulkDependentCustomResource primary, ExternalResource resource) { + ConfigMap configMap = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(configMapName(primary, resource)) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of(ExternalStateDependentReconciler.ID_KEY, resource.getId())) + .build(); + configMap.addOwnerReference(primary); + return configMap; + } + + @Override + public ExternalResource create( + ExternalResource desired, + ExternalStateBulkDependentCustomResource primary, + Context context) { + return externalService.create(desired); + } + + @Override + public ExternalResource update( + ExternalResource actual, + ExternalResource desired, + ExternalStateBulkDependentCustomResource primary, + Context context) { + return externalService.update(new ExternalResource(actual.getId(), desired.getData())); + } + + @Override + protected void handleDelete( + ExternalStateBulkDependentCustomResource primary, + ExternalResource secondary, + Context context) { + externalService.delete(secondary.getId()); + } + + @Override + public Matcher.Result match( + ExternalResource actualResource, + ExternalResource desired, + ExternalStateBulkDependentCustomResource primary, + Context context) { + return Matcher.Result.computed(desired.getData().equals(actualResource.getData()), desired); + } + + @Override + public Map desiredResources( + ExternalStateBulkDependentCustomResource primary, + Context context) { + int number = primary.getSpec().getNumber(); + Map res = new HashMap<>(); + for (int i = 0; i < number; i++) { + res.put( + Integer.toString(i), new ExternalResource(primary.getSpec().getData() + DELIMITER + i)); + } + return res; + } + + @Override + public Map getSecondaryResources( + ExternalStateBulkDependentCustomResource primary, + Context context) { + var resources = context.getSecondaryResources(ExternalResource.class); + return resources.stream().collect(Collectors.toMap(this::externalResourceIndex, r -> r)); + } + + @Override + public void handleDeleteTargetResource( + ExternalStateBulkDependentCustomResource primary, + ExternalResource resource, + String key, + Context context) { + externalService.delete(resource.getId()); + } + + private String externalResourceIndex(ExternalResource externalResource) { + return externalResource + .getData() + .substring(externalResource.getData().lastIndexOf(DELIMITER) + 1); + } + + private String configMapName( + ExternalStateBulkDependentCustomResource primary, ExternalResource resource) { + return primary.getMetadata().getName() + DELIMITER + externalResourceIndex(resource); + } + + @Override + public String keyFor(ExternalResource resource) { + return externalResourceIndex(resource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkDependentCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkDependentCustomResource.java new file mode 100644 index 0000000000..7f75f20de9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkDependentCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.externalstate.externalstatebulkdependent; + +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("esb") +public class ExternalStateBulkDependentCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkDependentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkDependentReconciler.java new file mode 100644 index 0000000000..34401e8754 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkDependentReconciler.java @@ -0,0 +1,45 @@ +package io.javaoperatorsdk.operator.dependent.externalstate.externalstatebulkdependent; + +import java.util.List; +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.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow(dependents = @Dependent(type = BulkDependentResourceExternalWithState.class)) +@ControllerConfiguration +public class ExternalStateBulkDependentReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + ExternalStateBulkDependentCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + var configMapEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, ExternalStateBulkDependentCustomResource.class) + .build(), + context); + return List.of(configMapEventSource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkIT.java new file mode 100644 index 0000000000..b2714fab47 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkIT.java @@ -0,0 +1,113 @@ +package io.javaoperatorsdk.operator.dependent.externalstate.externalstatebulkdependent; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.ExternalIDGenServiceMock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class ExternalStateBulkIT { + + private static final String TEST_RESOURCE_NAME = "test1"; + + public static final String INITIAL_TEST_DATA = "initialTestData"; + public static final String UPDATED_DATA = "updatedData"; + public static final int INITIAL_BULK_SIZE = 3; + public static final int INCREASED_BULK_SIZE = 4; + public static final int DECREASED_BULK_SIZE = 2; + + private final ExternalIDGenServiceMock externalService = ExternalIDGenServiceMock.getInstance(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(ExternalStateBulkDependentReconciler.class) + .build(); + + @Test + void reconcilesResourceWithPersistentState() { + var resource = operator.create(testResource()); + assertResources(resource, INITIAL_TEST_DATA, INITIAL_BULK_SIZE); + + resource.getSpec().setData(UPDATED_DATA); + resource = operator.replace(resource); + assertResources(resource, UPDATED_DATA, INITIAL_BULK_SIZE); + + resource.getSpec().setNumber(INCREASED_BULK_SIZE); + resource = operator.replace(resource); + assertResources(resource, UPDATED_DATA, INCREASED_BULK_SIZE); + + resource.getSpec().setNumber(DECREASED_BULK_SIZE); + resource = operator.replace(resource); + assertResources(resource, UPDATED_DATA, DECREASED_BULK_SIZE); + + operator.delete(resource); + assertResourcesDeleted(resource); + } + + private void assertResourcesDeleted(ExternalStateBulkDependentCustomResource resource) { + await() + .untilAsserted( + () -> { + var configMaps = + operator + .getKubernetesClient() + .configMaps() + .inNamespace(operator.getNamespace()) + .list() + .getItems() + .stream() + .filter( + cm -> + cm.getMetadata() + .getName() + .startsWith(resource.getMetadata().getName())); + var resources = externalService.listResources(); + assertThat(configMaps).isEmpty(); + assertThat(resources).isEmpty(); + }); + } + + private void assertResources( + ExternalStateBulkDependentCustomResource resource, String initialTestData, int size) { + await() + .pollInterval(Duration.ofMillis(700)) + .untilAsserted( + () -> { + var resources = externalService.listResources(); + assertThat(resources).hasSize(size); + assertThat(resources).allMatch(r -> r.getData().startsWith(initialTestData)); + + var configMaps = + operator + .getKubernetesClient() + .configMaps() + .inNamespace(operator.getNamespace()) + .list() + .getItems() + .stream() + .filter( + cm -> + cm.getMetadata() + .getName() + .startsWith(resource.getMetadata().getName())); + assertThat(configMaps).hasSize(size); + }); + } + + private ExternalStateBulkDependentCustomResource testResource() { + var res = new ExternalStateBulkDependentCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + + res.setSpec(new ExternalStateBulkSpec()); + res.getSpec().setNumber(INITIAL_BULK_SIZE); + res.getSpec().setData(INITIAL_TEST_DATA); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkSpec.java new file mode 100644 index 0000000000..a90a4575b5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/externalstate/externalstatebulkdependent/ExternalStateBulkSpec.java @@ -0,0 +1,25 @@ +package io.javaoperatorsdk.operator.dependent.externalstate.externalstatebulkdependent; + +public class ExternalStateBulkSpec { + + private Integer number; + private String data; + + public String getData() { + return data; + } + + public ExternalStateBulkSpec setData(String data) { + this.data = data; + return this; + } + + public Integer getNumber() { + return number; + } + + public ExternalStateBulkSpec setNumber(Integer number) { + this.number = number; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/GenericKubernetesDependentSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/GenericKubernetesDependentSpec.java new file mode 100644 index 0000000000..d2b33fe090 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/GenericKubernetesDependentSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.generickubernetesresource; + +public class GenericKubernetesDependentSpec { + + private String value; + + public String getValue() { + return value; + } + + public GenericKubernetesDependentSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/GenericKubernetesDependentTestBase.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/GenericKubernetesDependentTestBase.java new file mode 100644 index 0000000000..061c0218f0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/GenericKubernetesDependentTestBase.java @@ -0,0 +1,61 @@ +package io.javaoperatorsdk.operator.dependent.generickubernetesresource; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.client.CustomResource; +import io.javaoperatorsdk.operator.dependent.generickubernetesresource.generickubernetesdependentstandalone.ConfigMapGenericKubernetesDependent; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.IntegrationTestConstants.GARBAGE_COLLECTION_TIMEOUT_SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public abstract class GenericKubernetesDependentTestBase< + R extends CustomResource> { + + public static final String INITIAL_DATA = "Initial data"; + public static final String CHANGED_DATA = "Changed data"; + public static final String TEST_RESOURCE_NAME = "test1"; + + @Test + void testReconciliation() { + var resource = extension().create(testResource(TEST_RESOURCE_NAME, INITIAL_DATA)); + + await() + .untilAsserted( + () -> { + var cm = extension().get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(cm).isNotNull(); + assertThat(cm.getData()) + .containsEntry(ConfigMapGenericKubernetesDependent.KEY, INITIAL_DATA); + }); + + resource.getSpec().setValue(CHANGED_DATA); + resource = extension().replace(resource); + + await() + .untilAsserted( + () -> { + var cm = extension().get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(cm.getData()) + .containsEntry(ConfigMapGenericKubernetesDependent.KEY, CHANGED_DATA); + }); + + extension().delete(resource); + + await() + .timeout(Duration.ofSeconds(GARBAGE_COLLECTION_TIMEOUT_SECONDS)) + .untilAsserted( + () -> { + var cm = extension().get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(cm).isNull(); + }); + } + + public abstract LocallyRunOperatorExtension extension(); + + public abstract R testResource(String name, String data); +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/ConfigMapGenericKubernetesDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/ConfigMapGenericKubernetesDependent.java new file mode 100644 index 0000000000..25e0988ca0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/ConfigMapGenericKubernetesDependent.java @@ -0,0 +1,47 @@ +package io.javaoperatorsdk.operator.dependent.generickubernetesresource.generickubernetesdependentresourcemanaged; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Updater; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent +public class ConfigMapGenericKubernetesDependent + extends GenericKubernetesDependentResource + implements Creator, + Updater, + GarbageCollected { + + public static final String VERSION = "v1"; + public static final String KIND = "ConfigMap"; + public static final String KEY = "key"; + + public ConfigMapGenericKubernetesDependent() { + super(new GroupVersionKind("", VERSION, KIND)); + } + + @Override + protected GenericKubernetesResource desired( + GenericKubernetesDependentManagedCustomResource primary, + Context context) { + + try (InputStream is = this.getClass().getResourceAsStream("/configmap.yaml")) { + var res = context.getClient().genericKubernetesResources(VERSION, KIND).load(is).item(); + res.getMetadata().setName(primary.getMetadata().getName()); + res.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + Map data = (Map) res.getAdditionalProperties().get("data"); + data.put(KEY, primary.getSpec().getValue()); + return res; + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/GenericKubernetesDependentManagedCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/GenericKubernetesDependentManagedCustomResource.java new file mode 100644 index 0000000000..78e66ca74e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/GenericKubernetesDependentManagedCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.dependent.generickubernetesresource.generickubernetesdependentresourcemanaged; + +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; +import io.javaoperatorsdk.operator.dependent.generickubernetesresource.GenericKubernetesDependentSpec; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("gkdm") +public class GenericKubernetesDependentManagedCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/GenericKubernetesDependentManagedIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/GenericKubernetesDependentManagedIT.java new file mode 100644 index 0000000000..93fc34fbf0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/GenericKubernetesDependentManagedIT.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.dependent.generickubernetesresource.generickubernetesdependentresourcemanaged; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.dependent.generickubernetesresource.GenericKubernetesDependentSpec; +import io.javaoperatorsdk.operator.dependent.generickubernetesresource.GenericKubernetesDependentTestBase; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +public class GenericKubernetesDependentManagedIT + extends GenericKubernetesDependentTestBase { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new GenericKubernetesDependentManagedReconciler()) + .build(); + + @Override + public LocallyRunOperatorExtension extension() { + return extension; + } + + @Override + public GenericKubernetesDependentManagedCustomResource testResource(String name, String data) { + var resource = new GenericKubernetesDependentManagedCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(name).build()); + resource.setSpec(new GenericKubernetesDependentSpec()); + resource.getSpec().setValue(INITIAL_DATA); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/GenericKubernetesDependentManagedReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/GenericKubernetesDependentManagedReconciler.java new file mode 100644 index 0000000000..60c709ad08 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentresourcemanaged/GenericKubernetesDependentManagedReconciler.java @@ -0,0 +1,22 @@ +package io.javaoperatorsdk.operator.dependent.generickubernetesresource.generickubernetesdependentresourcemanaged; + +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.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow(dependents = {@Dependent(type = ConfigMapGenericKubernetesDependent.class)}) +@ControllerConfiguration +public class GenericKubernetesDependentManagedReconciler + implements Reconciler { + + @Override + public UpdateControl reconcile( + GenericKubernetesDependentManagedCustomResource resource, + Context context) { + + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/ConfigMapGenericKubernetesDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/ConfigMapGenericKubernetesDependent.java new file mode 100644 index 0000000000..1f694a2fa4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/ConfigMapGenericKubernetesDependent.java @@ -0,0 +1,46 @@ +package io.javaoperatorsdk.operator.dependent.generickubernetesresource.generickubernetesdependentstandalone; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Updater; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesDependentResource; + +public class ConfigMapGenericKubernetesDependent + extends GenericKubernetesDependentResource + implements Creator< + GenericKubernetesResource, GenericKubernetesDependentStandaloneCustomResource>, + Updater, + GarbageCollected { + + public static final String VERSION = "v1"; + public static final String KIND = "ConfigMap"; + public static final String KEY = "key"; + + public ConfigMapGenericKubernetesDependent() { + super(new GroupVersionKind("", VERSION, KIND)); + } + + @Override + protected GenericKubernetesResource desired( + GenericKubernetesDependentStandaloneCustomResource primary, + Context context) { + + try (InputStream is = this.getClass().getResourceAsStream("/configmap.yaml")) { + var res = context.getClient().genericKubernetesResources(VERSION, KIND).load(is).item(); + res.getMetadata().setName(primary.getMetadata().getName()); + res.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + Map data = (Map) res.getAdditionalProperties().get("data"); + data.put(KEY, primary.getSpec().getValue()); + return res; + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/GenericKubernetesDependentStandaloneCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/GenericKubernetesDependentStandaloneCustomResource.java new file mode 100644 index 0000000000..10776fdce1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/GenericKubernetesDependentStandaloneCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.dependent.generickubernetesresource.generickubernetesdependentstandalone; + +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; +import io.javaoperatorsdk.operator.dependent.generickubernetesresource.GenericKubernetesDependentSpec; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("gkd") +public class GenericKubernetesDependentStandaloneCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/GenericKubernetesDependentStandaloneIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/GenericKubernetesDependentStandaloneIT.java new file mode 100644 index 0000000000..9afdec5474 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/GenericKubernetesDependentStandaloneIT.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.dependent.generickubernetesresource.generickubernetesdependentstandalone; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.dependent.generickubernetesresource.GenericKubernetesDependentSpec; +import io.javaoperatorsdk.operator.dependent.generickubernetesresource.GenericKubernetesDependentTestBase; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +public class GenericKubernetesDependentStandaloneIT + extends GenericKubernetesDependentTestBase { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new GenericKubernetesDependentStandaloneReconciler()) + .build(); + + @Override + public LocallyRunOperatorExtension extension() { + return extension; + } + + @Override + public GenericKubernetesDependentStandaloneCustomResource testResource(String name, String data) { + var resource = new GenericKubernetesDependentStandaloneCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(name).build()); + resource.setSpec(new GenericKubernetesDependentSpec()); + resource.getSpec().setValue(INITIAL_DATA); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/GenericKubernetesDependentStandaloneReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/GenericKubernetesDependentStandaloneReconciler.java new file mode 100644 index 0000000000..9e29965d39 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/generickubernetesdependentstandalone/GenericKubernetesDependentStandaloneReconciler.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.dependent.generickubernetesresource.generickubernetesdependentstandalone; + +import java.util.List; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; + +@ControllerConfiguration +public class GenericKubernetesDependentStandaloneReconciler + implements Reconciler { + + private final ConfigMapGenericKubernetesDependent dependent = + new ConfigMapGenericKubernetesDependent(); + + public GenericKubernetesDependentStandaloneReconciler() {} + + @Override + public UpdateControl reconcile( + GenericKubernetesDependentStandaloneCustomResource resource, + Context context) { + + dependent.reconcile(resource, context); + + return UpdateControl.noUpdate(); + } + + @Override + public List> + prepareEventSources( + EventSourceContext context) { + return List.of(dependent.eventSource(context).orElseThrow()); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/ConfigMapDependentResource.java new file mode 100644 index 0000000000..348921cd93 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/ConfigMapDependentResource.java @@ -0,0 +1,33 @@ +package io.javaoperatorsdk.operator.dependent.informerrelatedbehavior; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent(informer = @Informer(labelSelector = "app=rbac-test")) +public class ConfigMapDependentResource + extends CRUDKubernetesDependentResource { + + public static final String DATA_KEY = "key"; + + @Override + protected ConfigMap desired( + InformerRelatedBehaviorTestCustomResource primary, + Context context) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withLabels(Map.of("app", "rbac-test")) + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of(DATA_KEY, primary.getMetadata().getName())) + .build(); + } +} 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 new file mode 100644 index 0000000000..ec50e058b9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorITS.java @@ -0,0 +1,423 @@ +package io.javaoperatorsdk.operator.dependent.informerrelatedbehavior; + +import java.time.Duration; + +import org.junit.jupiter.api.*; + +import io.fabric8.kubeapitest.junit.EnableKubeAPIServer; +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.Namespace; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.rbac.ClusterRole; +import io.fabric8.kubernetes.api.model.rbac.ClusterRoleBinding; +import io.fabric8.kubernetes.api.model.rbac.Role; +import io.fabric8.kubernetes.api.model.rbac.RoleBinding; +import io.fabric8.kubernetes.client.ConfigBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +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.health.InformerHealthIndicator; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; + +import static io.javaoperatorsdk.operator.dependent.informerrelatedbehavior.InformerRelatedBehaviorTestReconciler.CONFIG_MAP_DEPENDENT_RESOURCE; +import static io.javaoperatorsdk.operator.dependent.informerrelatedbehavior.InformerRelatedBehaviorTestReconciler.INFORMER_RELATED_BEHAVIOR_TEST_RECONCILER; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The test relies on a special api server configuration: "min-request-timeout" to have a very low + * value (in case want to try with minikube use: "minikube start + * --extra-config=apiserver.min-request-timeout=1") + * + *

This is important when tests are affected by permission changes, since the watch permissions + * are just checked when established a watch request. So minimal request timeout is set to make sure + * that with periodical watch reconnect the permission is tested again. + * + *

The test ends with "ITS" (Special) since it needs to run separately from other ITs + */ +@EnableKubeAPIServer( + apiServerFlags = {"--min-request-timeout", "1"}, + updateKubeConfigFile = true) +class InformerRelatedBehaviorITS { + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String ADDITIONAL_NAMESPACE_SUFFIX = "-additional"; + + KubernetesClient adminClient = new KubernetesClientBuilder().build(); + InformerRelatedBehaviorTestReconciler reconciler; + String actualNamespace; + String additionalNamespace; + Operator operator; + volatile boolean replacementStopHandlerCalled = false; + + @BeforeEach + void beforeEach(TestInfo testInfo) { + LocallyRunOperatorExtension.applyCrd( + InformerRelatedBehaviorTestCustomResource.class, adminClient); + testInfo + .getTestMethod() + .ifPresent( + method -> { + actualNamespace = KubernetesResourceUtil.sanitizeName(method.getName()); + additionalNamespace = actualNamespace + ADDITIONAL_NAMESPACE_SUFFIX; + adminClient.resource(namespace()).createOrReplace(); + }); + // cleans up binding before test, not all test cases use cluster role + removeClusterRoleBinding(); + } + + @AfterEach + void cleanup() { + if (operator != null) { + operator.stop(); + } + adminClient.resource(dependentConfigMap()).delete(); + adminClient.resource(testCustomResource()).delete(); + } + + @Test + void notStartsUpWithoutPermissionIfInstructed() { + adminClient.resource(testCustomResource()).createOrReplace(); + setNoCustomResourceAccess(); + + assertThrows(OperatorException.class, () -> startOperator(true)); + assertNotReconciled(); + } + + @Test + void startsUpWhenNoPermissionToCustomResource() { + adminClient.resource(testCustomResource()).createOrReplace(); + setNoCustomResourceAccess(); + + operator = startOperator(false); + assertNotReconciled(); + assertRuntimeInfoNoCRPermission(operator); + + setFullResourcesAccess(); + waitForWatchReconnect(); + assertReconciled(); + assertThat(operator.getRuntimeInfo().allEventSourcesAreHealthy()).isTrue(); + } + + @Test + void startsUpWhenNoPermissionToSecondaryResource() { + adminClient.resource(testCustomResource()).createOrReplace(); + setNoConfigMapAccess(); + + operator = startOperator(false); + assertNotReconciled(); + assertRuntimeInfoForSecondaryPermission(operator); + + setFullResourcesAccess(); + waitForWatchReconnect(); + assertReconciled(); + } + + @Test + void startsUpIfNoPermissionToOneOfTwoNamespaces() { + adminClient.resource(namespace(additionalNamespace)).createOrReplace(); + + addRoleBindingsToTestNamespaces(); + operator = startOperator(false, false, actualNamespace, additionalNamespace); + assertInformerNotWatchingForAdditionalNamespace(operator); + + adminClient.resource(testCustomResource()).createOrReplace(); + waitForWatchReconnect(); + assertReconciled(); + } + + private void assertInformerNotWatchingForAdditionalNamespace(Operator operator) { + assertThat(operator.getRuntimeInfo().allEventSourcesAreHealthy()).isFalse(); + var unhealthyEventSources = + operator + .getRuntimeInfo() + .unhealthyInformerWrappingEventSourceHealthIndicator() + .get(INFORMER_RELATED_BEHAVIOR_TEST_RECONCILER); + + InformerHealthIndicator controllerHealthIndicator = + (InformerHealthIndicator) + unhealthyEventSources + .get(ControllerEventSource.NAME) + .informerHealthIndicators() + .get(additionalNamespace); + assertThat(controllerHealthIndicator).isNotNull(); + assertThat(controllerHealthIndicator.getTargetNamespace()).isEqualTo(additionalNamespace); + assertThat(controllerHealthIndicator.isWatching()).isFalse(); + + InformerHealthIndicator configMapHealthIndicator = + (InformerHealthIndicator) + unhealthyEventSources + .get(InformerRelatedBehaviorTestReconciler.CONFIG_MAP_DEPENDENT_RESOURCE) + .informerHealthIndicators() + .get(additionalNamespace); + assertThat(configMapHealthIndicator).isNotNull(); + assertThat(configMapHealthIndicator.getTargetNamespace()).isEqualTo(additionalNamespace); + assertThat(configMapHealthIndicator.isWatching()).isFalse(); + } + + // this will be investigated separately under the issue below, it's not crucial functional wise, + // it is rather "something working why it should", not other way around; but it's not a + // showstopper + // https://github.com/operator-framework/java-operator-sdk/issues/1835 + @Disabled + @Test + void resilientForLoosingPermissionForCustomResource() { + setFullResourcesAccess(); + operator = startOperator(true); + setNoCustomResourceAccess(); + + waitForWatchReconnect(); + + adminClient.resource(testCustomResource()).createOrReplace(); + + assertNotReconciled(); + setFullResourcesAccess(); + assertReconciled(); + } + + @Test + void resilientForLoosingPermissionForSecondaryResource() { + setFullResourcesAccess(); + startOperator(true); + setNoConfigMapAccess(); + + waitForWatchReconnect(); + adminClient.resource(testCustomResource()).createOrReplace(); + + await() + .pollDelay(Duration.ofMillis(300)) + .untilAsserted( + () -> { + var cm = + adminClient + .configMaps() + .inNamespace(actualNamespace) + .withName(TEST_RESOURCE_NAME) + .get(); + assertThat(cm).isNull(); + }); + + setFullResourcesAccess(); + assertReconciled(); + } + + @Test + void callsStopHandlerOnStartupFail() { + setNoCustomResourceAccess(); + adminClient.resource(testCustomResource()).createOrReplace(); + + assertThrows(OperatorException.class, () -> startOperator(true)); + + await().untilAsserted(() -> assertThat(replacementStopHandlerCalled).isTrue()); + } + + @Test + void notExitingWithDefaultStopHandlerIfErrorHappensOnStartup() { + setNoCustomResourceAccess(); + adminClient.resource(testCustomResource()).createOrReplace(); + + assertThrows(OperatorException.class, () -> startOperator(true, false)); + + // note that we just basically check here that the default handler does not call system exit. + // Thus, the test does not terminate before to assert. + await().untilAsserted(() -> assertThat(replacementStopHandlerCalled).isFalse()); + } + + private static void waitForWatchReconnect() { + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + } + + private void assertNotReconciled() { + await() + .pollDelay(Duration.ofMillis(2000)) + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions()).isEqualTo(0); + }); + } + + InformerRelatedBehaviorTestCustomResource testCustomResource() { + InformerRelatedBehaviorTestCustomResource testCustomResource = + new InformerRelatedBehaviorTestCustomResource(); + testCustomResource.setMetadata( + new ObjectMetaBuilder() + .withNamespace(actualNamespace) + .withName(TEST_RESOURCE_NAME) + .build()); + return testCustomResource; + } + + private ConfigMap dependentConfigMap() { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(TEST_RESOURCE_NAME) + .withNamespace(actualNamespace) + .build()) + .build(); + } + + private void assertReconciled() { + await() + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions()).isGreaterThan(0); + var cm = + adminClient + .configMaps() + .inNamespace(actualNamespace) + .withName(TEST_RESOURCE_NAME) + .get(); + assertThat(cm).isNotNull(); + }); + } + + @SuppressWarnings("unchecked") + private void assertRuntimeInfoNoCRPermission(Operator operator) { + assertThat(operator.getRuntimeInfo().allEventSourcesAreHealthy()).isFalse(); + var unhealthyEventSources = + operator + .getRuntimeInfo() + .unhealthyEventSources() + .get(INFORMER_RELATED_BEHAVIOR_TEST_RECONCILER); + assertThat(unhealthyEventSources).isNotEmpty(); + assertThat(unhealthyEventSources.get(ControllerEventSource.NAME)).isNotNull(); + var informerHealthIndicators = + operator + .getRuntimeInfo() + .unhealthyInformerWrappingEventSourceHealthIndicator() + .get(INFORMER_RELATED_BEHAVIOR_TEST_RECONCILER); + assertThat(informerHealthIndicators).isNotEmpty(); + assertThat(informerHealthIndicators.get(ControllerEventSource.NAME).informerHealthIndicators()) + .hasSize(1); + } + + @SuppressWarnings("unchecked") + private void assertRuntimeInfoForSecondaryPermission(Operator operator) { + assertThat(operator.getRuntimeInfo().allEventSourcesAreHealthy()).isFalse(); + var unhealthyEventSources = + operator + .getRuntimeInfo() + .unhealthyEventSources() + .get(INFORMER_RELATED_BEHAVIOR_TEST_RECONCILER); + assertThat(unhealthyEventSources).isNotEmpty(); + assertThat(unhealthyEventSources.get(CONFIG_MAP_DEPENDENT_RESOURCE)).isNotNull(); + var informerHealthIndicators = + operator + .getRuntimeInfo() + .unhealthyInformerWrappingEventSourceHealthIndicator() + .get(INFORMER_RELATED_BEHAVIOR_TEST_RECONCILER); + assertThat(informerHealthIndicators).isNotEmpty(); + assertThat( + informerHealthIndicators.get(CONFIG_MAP_DEPENDENT_RESOURCE).informerHealthIndicators()) + .hasSize(1); + } + + KubernetesClient clientUsingServiceAccount() { + KubernetesClient client = + new KubernetesClientBuilder() + .withConfig( + new ConfigBuilder() + .withImpersonateUsername("rbac-test-user") + .withNamespace(actualNamespace) + .build()) + .build(); + return client; + } + + Operator startOperator(boolean stopOnInformerErrorDuringStartup) { + return startOperator(stopOnInformerErrorDuringStartup, true); + } + + Operator startOperator( + boolean stopOnInformerErrorDuringStartup, boolean addStopHandler, String... namespaces) { + + reconciler = new InformerRelatedBehaviorTestReconciler(); + + Operator operator = + new Operator( + co -> { + co.withKubernetesClient(clientUsingServiceAccount()); + co.withStopOnInformerErrorDuringStartup(stopOnInformerErrorDuringStartup); + co.withCacheSyncTimeout(Duration.ofMillis(3000)); + co.withReconciliationTerminationTimeout(Duration.ofSeconds(1)); + if (addStopHandler) { + co.withInformerStoppedHandler( + (informer, ex) -> replacementStopHandlerCalled = true); + } + }); + operator.register( + reconciler, + o -> { + if (namespaces.length > 0) { + o.settingNamespaces(namespaces); + } + }); + operator.start(); + return operator; + } + + private void setNoConfigMapAccess() { + applyClusterRole("rbac-test-no-configmap-access.yaml"); + applyClusterRoleBinding(); + } + + private void setNoCustomResourceAccess() { + applyClusterRole("rbac-test-no-cr-access.yaml"); + applyClusterRoleBinding(); + } + + private void setFullResourcesAccess() { + applyClusterRole("rbac-test-full-access-role.yaml"); + applyClusterRoleBinding(); + } + + private void addRoleBindingsToTestNamespaces() { + var role = + ReconcilerUtils.loadYaml(Role.class, this.getClass(), "rbac-test-only-main-ns-access.yaml"); + adminClient.resource(role).inNamespace(actualNamespace).createOrReplace(); + var roleBinding = + ReconcilerUtils.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( + 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); + adminClient.resource(clusterRole).createOrReplace(); + } + + private Namespace namespace() { + return namespace(actualNamespace); + } + + private Namespace namespace(String name) { + Namespace n = new Namespace(); + n.setMetadata(new ObjectMetaBuilder().withName(name).build()); + return n; + } + + private void removeClusterRoleBinding() { + var clusterRoleBinding = + ReconcilerUtils.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/informerrelatedbehavior/InformerRelatedBehaviorTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorTestCustomResource.java new file mode 100644 index 0000000000..8f4c603d81 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorTestCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.informerrelatedbehavior; + +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("rbt") +public class InformerRelatedBehaviorTestCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorTestReconciler.java new file mode 100644 index 0000000000..61c07fb88d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/InformerRelatedBehaviorTestReconciler.java @@ -0,0 +1,44 @@ +package io.javaoperatorsdk.operator.dependent.informerrelatedbehavior; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow( + dependents = + @Dependent( + name = InformerRelatedBehaviorTestReconciler.CONFIG_MAP_DEPENDENT_RESOURCE, + type = ConfigMapDependentResource.class)) +@ControllerConfiguration( + name = InformerRelatedBehaviorTestReconciler.INFORMER_RELATED_BEHAVIOR_TEST_RECONCILER) +public class InformerRelatedBehaviorTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private static final Logger log = + LoggerFactory.getLogger(InformerRelatedBehaviorTestReconciler.class); + + public static final String INFORMER_RELATED_BEHAVIOR_TEST_RECONCILER = + "InformerRelatedBehaviorTestReconciler"; + public static final String CONFIG_MAP_DEPENDENT_RESOURCE = "ConfigMapDependentResource"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + InformerRelatedBehaviorTestCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + log.info("Reconciled for: {}", ResourceID.fromResource(resource)); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/DependentGarbageCollectionTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/DependentGarbageCollectionTestCustomResource.java new file mode 100644 index 0000000000..dbe944c6fa --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/DependentGarbageCollectionTestCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.dependent.kubernetesdependentgarbagecollection; + +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("dgc") +public class DependentGarbageCollectionTestCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/DependentGarbageCollectionTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/DependentGarbageCollectionTestCustomResourceSpec.java new file mode 100644 index 0000000000..0571bbf97d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/DependentGarbageCollectionTestCustomResourceSpec.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.dependent.kubernetesdependentgarbagecollection; + +public class DependentGarbageCollectionTestCustomResourceSpec { + + private boolean createConfigMap; + + public boolean isCreateConfigMap() { + return createConfigMap; + } + + public DependentGarbageCollectionTestCustomResourceSpec setCreateConfigMap( + boolean createConfigMap) { + this.createConfigMap = createConfigMap; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/DependentGarbageCollectionTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/DependentGarbageCollectionTestReconciler.java new file mode 100644 index 0000000000..880aaa6884 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/DependentGarbageCollectionTestReconciler.java @@ -0,0 +1,87 @@ +package io.javaoperatorsdk.operator.dependent.kubernetesdependentgarbagecollection; + +import java.util.List; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Updater; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; + +@ControllerConfiguration +public class DependentGarbageCollectionTestReconciler + implements Reconciler { + + private KubernetesClient kubernetesClient; + private volatile boolean errorOccurred = false; + + ConfigMapDependentResource configMapDependent; + + public DependentGarbageCollectionTestReconciler() { + configMapDependent = new ConfigMapDependentResource(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + return EventSourceUtils.dependentEventSources(context, configMapDependent); + } + + @Override + public UpdateControl reconcile( + DependentGarbageCollectionTestCustomResource primary, + Context context) { + + if (primary.getSpec().isCreateConfigMap()) { + configMapDependent.reconcile(primary, context); + } else { + configMapDependent.delete(primary, context); + } + + return UpdateControl.noUpdate(); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + DependentGarbageCollectionTestCustomResource resource, + Context context, + Exception e) { + // this can happen when a namespace is terminated in test + if (e instanceof KubernetesClientException) { + return ErrorStatusUpdateControl.noStatusUpdate(); + } + errorOccurred = true; + return ErrorStatusUpdateControl.noStatusUpdate(); + } + + public boolean isErrorOccurred() { + return errorOccurred; + } + + private static class ConfigMapDependentResource + extends KubernetesDependentResource + implements Creator, + Updater, + GarbageCollected { + + @Override + protected ConfigMap desired( + DependentGarbageCollectionTestCustomResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of("key", "data")); + return configMap; + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/KubernetesDependentGarbageCollectionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/KubernetesDependentGarbageCollectionIT.java new file mode 100644 index 0000000000..8e08c4e739 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/kubernetesdependentgarbagecollection/KubernetesDependentGarbageCollectionIT.java @@ -0,0 +1,85 @@ +package io.javaoperatorsdk.operator.dependent.kubernetesdependentgarbagecollection; + +import java.time.Duration; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.IntegrationTestConstants; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class KubernetesDependentGarbageCollectionIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new DependentGarbageCollectionTestReconciler()) + .build(); + + @Test + void resourceSecondaryResourceIsGarbageCollected() { + var resource = customResource(); + var createdResources = operator.create(resource); + + await() + .untilAsserted( + () -> { + ConfigMap configMap = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(configMap).isNotNull(); + }); + + ConfigMap configMap = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(configMap.getMetadata().getOwnerReferences()).hasSize(1); + assertThat(configMap.getMetadata().getOwnerReferences().get(0).getName()) + .isEqualTo(TEST_RESOURCE_NAME); + + operator.delete(createdResources); + + await() + .atMost(Duration.ofSeconds(IntegrationTestConstants.GARBAGE_COLLECTION_TIMEOUT_SECONDS)) + .untilAsserted( + () -> { + ConfigMap cm = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(cm).isNull(); + }); + } + + @Test + void deletesSecondaryResource() { + var resource = customResource(); + var createdResources = operator.create(resource); + + await() + .untilAsserted( + () -> { + ConfigMap configMap = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(configMap).isNotNull(); + }); + + createdResources.getSpec().setCreateConfigMap(false); + operator.replace(createdResources); + + await() + .untilAsserted( + () -> { + ConfigMap cm = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(cm).isNull(); + }); + } + + DependentGarbageCollectionTestCustomResource customResource() { + DependentGarbageCollectionTestCustomResource resource = + new DependentGarbageCollectionTestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + resource.setSpec(new DependentGarbageCollectionTestCustomResourceSpec()); + resource.getSpec().setCreateConfigMap(true); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceConfigMap.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceConfigMap.java new file mode 100644 index 0000000000..7f895370f5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceConfigMap.java @@ -0,0 +1,38 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentresource; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class MultipleDependentResourceConfigMap + extends CRUDKubernetesDependentResource { + + public static final String DATA_KEY = "key"; + private final String value; + + public MultipleDependentResourceConfigMap(String value) { + super(ConfigMap.class); + this.value = value; + } + + @Override + protected ConfigMap desired( + MultipleDependentResourceCustomResource primary, + Context context) { + + return new ConfigMapBuilder() + .withNewMetadata() + .withName(getConfigMapName(value)) + .withNamespace(primary.getMetadata().getNamespace()) + .endMetadata() + .withData(Map.of(DATA_KEY, primary.getSpec().getValue())) + .build(); + } + + public static String getConfigMapName(String id) { + return "configmap" + id; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceCustomResource.java new file mode 100644 index 0000000000..b9b052e2b8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentresource; + +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("mdr") +public class MultipleDependentResourceCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceIT.java new file mode 100644 index 0000000000..a7dfefdaa8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceIT.java @@ -0,0 +1,81 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentresource; + +import java.time.Duration; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.dependent.multipledependentresource.MultipleDependentResourceConfigMap.DATA_KEY; +import static io.javaoperatorsdk.operator.dependent.multipledependentresource.MultipleDependentResourceConfigMap.getConfigMapName; +import static io.javaoperatorsdk.operator.dependent.multipledependentresource.MultipleDependentResourceReconciler.FIRST_CONFIG_MAP_ID; +import static io.javaoperatorsdk.operator.dependent.multipledependentresource.MultipleDependentResourceReconciler.SECOND_CONFIG_MAP_ID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class MultipleDependentResourceIT { + + public static final String CHANGED_VALUE = "changed value"; + public static final String INITIAL_VALUE = "initial value"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new MultipleDependentResourceReconciler()) + .build(); + + @Test + void handlesCRUDOperations() { + var res = extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var cm1 = extension.get(ConfigMap.class, getConfigMapName(FIRST_CONFIG_MAP_ID)); + var cm2 = extension.get(ConfigMap.class, getConfigMapName(SECOND_CONFIG_MAP_ID)); + + assertThat(cm1).isNotNull(); + assertThat(cm2).isNotNull(); + assertThat(cm1.getData()).containsEntry(DATA_KEY, INITIAL_VALUE); + assertThat(cm2.getData()).containsEntry(DATA_KEY, INITIAL_VALUE); + }); + + res.getSpec().setValue(CHANGED_VALUE); + res = extension.replace(res); + + await() + .untilAsserted( + () -> { + var cm1 = extension.get(ConfigMap.class, getConfigMapName(FIRST_CONFIG_MAP_ID)); + var cm2 = extension.get(ConfigMap.class, getConfigMapName(SECOND_CONFIG_MAP_ID)); + + assertThat(cm1.getData()).containsEntry(DATA_KEY, CHANGED_VALUE); + assertThat(cm2.getData()).containsEntry(DATA_KEY, CHANGED_VALUE); + }); + + extension.delete(res); + + await() + .timeout(Duration.ofSeconds(120)) + .untilAsserted( + () -> { + var cm1 = extension.get(ConfigMap.class, getConfigMapName(FIRST_CONFIG_MAP_ID)); + var cm2 = extension.get(ConfigMap.class, getConfigMapName(SECOND_CONFIG_MAP_ID)); + + assertThat(cm1).isNull(); + assertThat(cm2).isNull(); + }); + } + + MultipleDependentResourceCustomResource testResource() { + var res = new MultipleDependentResourceCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName("test1").build()); + res.setSpec(new MultipleDependentResourceSpec()); + res.getSpec().setValue(INITIAL_VALUE); + + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceReconciler.java new file mode 100644 index 0000000000..f4088f36f1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceReconciler.java @@ -0,0 +1,49 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentresource; + +import java.util.List; + +import io.fabric8.kubernetes.api.model.ConfigMap; +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; + +@ControllerConfiguration +public class MultipleDependentResourceReconciler + implements Reconciler { + + public static final String FIRST_CONFIG_MAP_ID = "1"; + public static final String SECOND_CONFIG_MAP_ID = "2"; + + private final MultipleDependentResourceConfigMap firstDependentResourceConfigMap; + private final MultipleDependentResourceConfigMap secondDependentResourceConfigMap; + + public MultipleDependentResourceReconciler() { + firstDependentResourceConfigMap = new MultipleDependentResourceConfigMap(FIRST_CONFIG_MAP_ID); + secondDependentResourceConfigMap = new MultipleDependentResourceConfigMap(SECOND_CONFIG_MAP_ID); + } + + @Override + public UpdateControl reconcile( + MultipleDependentResourceCustomResource resource, + Context context) { + firstDependentResourceConfigMap.reconcile(resource, context); + secondDependentResourceConfigMap.reconcile(resource, context); + return UpdateControl.noUpdate(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + InformerEventSource eventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, MultipleDependentResourceCustomResource.class) + .build(), + context); + firstDependentResourceConfigMap.setEventSource(eventSource); + secondDependentResourceConfigMap.setEventSource(eventSource); + + return List.of(eventSource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceSpec.java new file mode 100644 index 0000000000..96f7224d31 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresource/MultipleDependentResourceSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentresource; + +public class MultipleDependentResourceSpec { + + 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/dependent/multipledependentresourcewithsametype/MultipleDependentResourceConfigMap.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceConfigMap.java new file mode 100644 index 0000000000..2708e19657 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceConfigMap.java @@ -0,0 +1,38 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentresourcewithsametype; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class MultipleDependentResourceConfigMap + extends CRUDKubernetesDependentResource< + ConfigMap, MultipleDependentResourceCustomResourceNoDiscriminator> { + + public static final String DATA_KEY = "key"; + private final int value; + + public MultipleDependentResourceConfigMap(int value) { + super(ConfigMap.class); + this.value = value; + } + + @Override + protected ConfigMap desired( + MultipleDependentResourceCustomResourceNoDiscriminator primary, + Context context) { + Map data = new HashMap<>(); + data.put(DATA_KEY, String.valueOf(value)); + + return new ConfigMapBuilder() + .withNewMetadata() + .withName(primary.getConfigMapName(value)) + .withNamespace(primary.getMetadata().getNamespace()) + .endMetadata() + .withData(data) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceCustomResourceNoDiscriminator.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceCustomResourceNoDiscriminator.java new file mode 100644 index 0000000000..b8af874b23 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceCustomResourceNoDiscriminator.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentresourcewithsametype; + +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("mdwd") +public class MultipleDependentResourceCustomResourceNoDiscriminator + extends CustomResource implements Namespaced { + + public String getConfigMapName(int id) { + return "configmap" + id; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceWithDiscriminatorReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceWithDiscriminatorReconciler.java new file mode 100644 index 0000000000..288ba2bb97 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceWithDiscriminatorReconciler.java @@ -0,0 +1,61 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentresourcewithsametype; + +import java.util.List; +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.*; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration +public class MultipleDependentResourceWithDiscriminatorReconciler + implements Reconciler, + TestExecutionInfoProvider { + + public static final int FIRST_CONFIG_MAP_ID = 1; + public static final int SECOND_CONFIG_MAP_ID = 2; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + private final MultipleDependentResourceConfigMap firstDependentResourceConfigMap; + private final MultipleDependentResourceConfigMap secondDependentResourceConfigMap; + + public MultipleDependentResourceWithDiscriminatorReconciler() { + firstDependentResourceConfigMap = new MultipleDependentResourceConfigMap(FIRST_CONFIG_MAP_ID); + secondDependentResourceConfigMap = new MultipleDependentResourceConfigMap(SECOND_CONFIG_MAP_ID); + } + + @Override + public UpdateControl reconcile( + MultipleDependentResourceCustomResourceNoDiscriminator resource, + Context context) { + numberOfExecutions.getAndIncrement(); + firstDependentResourceConfigMap.reconcile(resource, context); + secondDependentResourceConfigMap.reconcile(resource, context); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> + prepareEventSources( + EventSourceContext context) { + InformerEventSource + eventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, + MultipleDependentResourceCustomResourceNoDiscriminator.class) + .build(), + context); + firstDependentResourceConfigMap.setEventSource(eventSource); + secondDependentResourceConfigMap.setEventSource(eventSource); + + return List.of(eventSource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceWithNoDiscriminatorIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceWithNoDiscriminatorIT.java new file mode 100644 index 0000000000..8be51ebf1a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentresourcewithsametype/MultipleDependentResourceWithNoDiscriminatorIT.java @@ -0,0 +1,63 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentresourcewithsametype; + +import java.time.Duration; +import java.util.stream.IntStream; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class MultipleDependentResourceWithNoDiscriminatorIT { + + public static final String TEST_RESOURCE_NAME = "multipledependentresource-testresource"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(MultipleDependentResourceWithDiscriminatorReconciler.class) + .waitForNamespaceDeletion(true) + .build(); + + @Test + void twoConfigMapsHaveBeenCreated() { + MultipleDependentResourceCustomResourceNoDiscriminator customResource = + createTestCustomResource(); + operator.create(customResource); + + var reconciler = + operator.getReconcilerOfType(MultipleDependentResourceWithDiscriminatorReconciler.class); + + await().pollDelay(Duration.ofMillis(300)).until(() -> reconciler.getNumberOfExecutions() <= 1); + + IntStream.of( + MultipleDependentResourceWithDiscriminatorReconciler.FIRST_CONFIG_MAP_ID, + MultipleDependentResourceWithDiscriminatorReconciler.SECOND_CONFIG_MAP_ID) + .forEach( + configMapId -> { + ConfigMap configMap = + operator.get(ConfigMap.class, customResource.getConfigMapName(configMapId)); + assertThat(configMap).isNotNull(); + assertThat(configMap.getMetadata().getName()) + .isEqualTo(customResource.getConfigMapName(configMapId)); + assertThat(configMap.getData().get(MultipleDependentResourceConfigMap.DATA_KEY)) + .isEqualTo(String.valueOf(configMapId)); + }); + } + + public MultipleDependentResourceCustomResourceNoDiscriminator createTestCustomResource() { + MultipleDependentResourceCustomResourceNoDiscriminator resource = + new MultipleDependentResourceCustomResourceNoDiscriminator(); + resource.setMetadata( + new ObjectMetaBuilder() + .withName(TEST_RESOURCE_NAME) + .withNamespace(operator.getNamespace()) + .build()); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleDependentSameTypeMultiInformerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleDependentSameTypeMultiInformerIT.java new file mode 100644 index 0000000000..5ba6d56be3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleDependentSameTypeMultiInformerIT.java @@ -0,0 +1,90 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentsametypemultiinformer; + +import java.time.Duration; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.IntegrationTestConstants.GARBAGE_COLLECTION_TIMEOUT_SECONDS; +import static io.javaoperatorsdk.operator.dependent.multipledependentsametypemultiinformer.MultipleManagedDependentResourceMultiInformerReconciler.DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class MultipleDependentSameTypeMultiInformerIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String DEFAULT_SPEC_VALUE = "val"; + public static final String UPDATED_SPEC_VALUE = "updated-val"; + public static final int SECONDS = 30; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new MultipleManagedDependentResourceMultiInformerReconciler()) + .build(); + + @Test + void handlesCrudOperations() { + operator.create(testResource()); + assertConfigMapsPresent(DEFAULT_SPEC_VALUE); + + var updatedResource = testResource(); + updatedResource.getSpec().setValue(UPDATED_SPEC_VALUE); + operator.replace(updatedResource); + assertConfigMapsPresent(UPDATED_SPEC_VALUE); + + operator.delete(testResource()); + assertConfigMapsDeleted(); + } + + private void assertConfigMapsPresent(String expectedData) { + await() + .untilAsserted( + () -> { + var maps = + operator + .getKubernetesClient() + .configMaps() + .inNamespace(operator.getNamespace()) + .list() + .getItems() + .stream() + .filter(cm -> cm.getMetadata().getName().startsWith(TEST_RESOURCE_NAME)) + .collect(Collectors.toList()); + assertThat(maps).hasSize(2); + assertThat(maps).allMatch(cm -> cm.getData().get(DATA_KEY).equals(expectedData)); + }); + } + + private void assertConfigMapsDeleted() { + await() + .atMost(Duration.ofSeconds(GARBAGE_COLLECTION_TIMEOUT_SECONDS)) + .untilAsserted( + () -> { + var maps = + operator + .getKubernetesClient() + .configMaps() + .inNamespace(operator.getNamespace()) + .list() + .getItems() + .stream() + .filter(cm -> cm.getMetadata().getName().startsWith(TEST_RESOURCE_NAME)) + .collect(Collectors.toList()); + assertThat(maps).hasSize(0); + }); + } + + private MultipleManagedDependentResourceMultiInformerCustomResource testResource() { + var res = new MultipleManagedDependentResourceMultiInformerCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + + res.setSpec(new MultipleManagedDependentResourceMultiInformerSpec()); + res.getSpec().setValue(DEFAULT_SPEC_VALUE); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerConfigMap1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerConfigMap1.java new file mode 100644 index 0000000000..855944ef98 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerConfigMap1.java @@ -0,0 +1,35 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentsametypemultiinformer; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype.MultipleManagedDependentResourceReconciler; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent +public class MultipleManagedDependentResourceMultiInformerConfigMap1 + extends CRUDKubernetesDependentResource< + ConfigMap, MultipleManagedDependentResourceMultiInformerCustomResource> { + + public static final String NAME_SUFFIX = "-1"; + + @Override + protected ConfigMap desired( + MultipleManagedDependentResourceMultiInformerCustomResource primary, + Context context) { + Map data = new HashMap<>(); + data.put(MultipleManagedDependentResourceReconciler.DATA_KEY, primary.getSpec().getValue()); + + return new ConfigMapBuilder() + .withNewMetadata() + .withName(primary.getMetadata().getName() + NAME_SUFFIX) + .withNamespace(primary.getMetadata().getNamespace()) + .endMetadata() + .withData(data) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerConfigMap2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerConfigMap2.java new file mode 100644 index 0000000000..7b28b322b7 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerConfigMap2.java @@ -0,0 +1,36 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentsametypemultiinformer; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +import static io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype.MultipleManagedDependentResourceReconciler.DATA_KEY; + +@KubernetesDependent +public class MultipleManagedDependentResourceMultiInformerConfigMap2 + extends CRUDKubernetesDependentResource< + ConfigMap, MultipleManagedDependentResourceMultiInformerCustomResource> { + + public static final String NAME_SUFFIX = "-2"; + + @Override + protected ConfigMap desired( + MultipleManagedDependentResourceMultiInformerCustomResource primary, + Context context) { + Map data = new HashMap<>(); + data.put(DATA_KEY, primary.getSpec().getValue()); + + return new ConfigMapBuilder() + .withNewMetadata() + .withName(primary.getMetadata().getName() + NAME_SUFFIX) + .withNamespace(primary.getMetadata().getNamespace()) + .endMetadata() + .withData(data) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerCustomResource.java new file mode 100644 index 0000000000..ad15168294 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentsametypemultiinformer; + +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("mmi") +public class MultipleManagedDependentResourceMultiInformerCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerReconciler.java new file mode 100644 index 0000000000..59c53b8594 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerReconciler.java @@ -0,0 +1,43 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentsametypemultiinformer; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow( + dependents = { + @Dependent( + name = MultipleManagedDependentResourceMultiInformerReconciler.CONFIG_MAP_1_DR, + type = MultipleManagedDependentResourceMultiInformerConfigMap1.class), + @Dependent( + name = MultipleManagedDependentResourceMultiInformerReconciler.CONFIG_MAP_2_DR, + type = MultipleManagedDependentResourceMultiInformerConfigMap2.class) + }) +@ControllerConfiguration +public class MultipleManagedDependentResourceMultiInformerReconciler + implements Reconciler, + TestExecutionInfoProvider { + + public static final String DATA_KEY = "key"; + public static final String CONFIG_MAP_1_DR = "ConfigMap1"; + public static final String CONFIG_MAP_2_DR = "ConfigMap2"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + public MultipleManagedDependentResourceMultiInformerReconciler() {} + + @Override + public UpdateControl reconcile( + MultipleManagedDependentResourceMultiInformerCustomResource resource, + Context context) { + numberOfExecutions.getAndIncrement(); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerSpec.java new file mode 100644 index 0000000000..c8893adc72 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledependentsametypemultiinformer/MultipleManagedDependentResourceMultiInformerSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.multipledependentsametypemultiinformer; + +public class MultipleManagedDependentResourceMultiInformerSpec { + + private String value; + + public String getValue() { + return value; + } + + public MultipleManagedDependentResourceMultiInformerSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorConfigMap1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorConfigMap1.java new file mode 100644 index 0000000000..2fb65c6dde --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorConfigMap1.java @@ -0,0 +1,50 @@ +package io.javaoperatorsdk.operator.dependent.multipledrsametypenodiscriminator; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +@KubernetesDependent +public class MultipleManagedDependentNoDiscriminatorConfigMap1 + extends CRUDKubernetesDependentResource< + ConfigMap, MultipleManagedDependentNoDiscriminatorCustomResource> { + + public static final String NAME_SUFFIX = "-1"; + + /* + * Showcases optimization to avoid computing the whole desired state by providing the ResourceID + * of the target resource. In this particular case this would not be necessary, since desired + * state creation is pretty lightweight. However, this might make sense in situation where the + * desired state is more costly + */ + protected ResourceID targetSecondaryResourceID( + MultipleManagedDependentNoDiscriminatorCustomResource primary, + Context context) { + return new ResourceID( + primary.getMetadata().getName() + NAME_SUFFIX, primary.getMetadata().getNamespace()); + } + + @Override + protected ConfigMap desired( + MultipleManagedDependentNoDiscriminatorCustomResource primary, + Context context) { + Map data = new HashMap<>(); + data.put( + MultipleManagedDependentSameTypeNoDiscriminatorReconciler.DATA_KEY, + primary.getSpec().getValue()); + + return new ConfigMapBuilder() + .withNewMetadata() + .withName(primary.getMetadata().getName() + NAME_SUFFIX) + .withNamespace(primary.getMetadata().getNamespace()) + .endMetadata() + .withData(data) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorConfigMap2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorConfigMap2.java new file mode 100644 index 0000000000..4455ef5d9b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorConfigMap2.java @@ -0,0 +1,36 @@ +package io.javaoperatorsdk.operator.dependent.multipledrsametypenodiscriminator; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +import static io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype.MultipleManagedDependentResourceReconciler.DATA_KEY; + +@KubernetesDependent +public class MultipleManagedDependentNoDiscriminatorConfigMap2 + extends CRUDKubernetesDependentResource< + ConfigMap, MultipleManagedDependentNoDiscriminatorCustomResource> { + + public static final String NAME_SUFFIX = "-2"; + + @Override + protected ConfigMap desired( + MultipleManagedDependentNoDiscriminatorCustomResource primary, + Context context) { + Map data = new HashMap<>(); + data.put(DATA_KEY, primary.getSpec().getValue()); + + return new ConfigMapBuilder() + .withNewMetadata() + .withName(primary.getMetadata().getName() + NAME_SUFFIX) + .withNamespace(primary.getMetadata().getNamespace()) + .endMetadata() + .withData(data) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorCustomResource.java new file mode 100644 index 0000000000..5af2ffec44 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.dependent.multipledrsametypenodiscriminator; + +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("mnd") +public class MultipleManagedDependentNoDiscriminatorCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorIT.java new file mode 100644 index 0000000000..1ed1da56f9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorIT.java @@ -0,0 +1,102 @@ +package io.javaoperatorsdk.operator.dependent.multipledrsametypenodiscriminator; + +import java.time.Duration; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.dependent.multipledrsametypenodiscriminator.MultipleManagedDependentSameTypeNoDiscriminatorReconciler.DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class MultipleManagedDependentNoDiscriminatorIT { + + public static final String RESOURCE_NAME = "test1"; + public static final String INITIAL_VALUE = "initial_value"; + public static final String CHANGED_VALUE = "changed_value"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new MultipleManagedDependentSameTypeNoDiscriminatorReconciler()) + .build(); + + @Test + void handlesCRUDOperations() { + var res = extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var cm1 = + extension.get( + ConfigMap.class, + RESOURCE_NAME + + MultipleManagedDependentNoDiscriminatorConfigMap1.NAME_SUFFIX); + var cm2 = + extension.get( + ConfigMap.class, + RESOURCE_NAME + + MultipleManagedDependentNoDiscriminatorConfigMap2.NAME_SUFFIX); + + assertThat(cm1).isNotNull(); + assertThat(cm2).isNotNull(); + assertThat(cm1.getData()).containsEntry(DATA_KEY, INITIAL_VALUE); + assertThat(cm2.getData()).containsEntry(DATA_KEY, INITIAL_VALUE); + }); + + res.getSpec().setValue(CHANGED_VALUE); + res = extension.replace(res); + + await() + .untilAsserted( + () -> { + var cm1 = + extension.get( + ConfigMap.class, + RESOURCE_NAME + + MultipleManagedDependentNoDiscriminatorConfigMap1.NAME_SUFFIX); + var cm2 = + extension.get( + ConfigMap.class, + RESOURCE_NAME + + MultipleManagedDependentNoDiscriminatorConfigMap2.NAME_SUFFIX); + + assertThat(cm1.getData()).containsEntry(DATA_KEY, CHANGED_VALUE); + assertThat(cm2.getData()).containsEntry(DATA_KEY, CHANGED_VALUE); + }); + + extension.delete(res); + + await() + .timeout(Duration.ofSeconds(60)) + .untilAsserted( + () -> { + var cm1 = + extension.get( + ConfigMap.class, + RESOURCE_NAME + + MultipleManagedDependentNoDiscriminatorConfigMap1.NAME_SUFFIX); + var cm2 = + extension.get( + ConfigMap.class, + RESOURCE_NAME + + MultipleManagedDependentNoDiscriminatorConfigMap2.NAME_SUFFIX); + + assertThat(cm1).isNull(); + assertThat(cm2).isNull(); + }); + } + + MultipleManagedDependentNoDiscriminatorCustomResource testResource() { + var res = new MultipleManagedDependentNoDiscriminatorCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + res.setSpec(new MultipleManagedDependentNoDiscriminatorSpec()); + res.getSpec().setValue(INITIAL_VALUE); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorSpec.java new file mode 100644 index 0000000000..5ee30673d4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentNoDiscriminatorSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.multipledrsametypenodiscriminator; + +public class MultipleManagedDependentNoDiscriminatorSpec { + + private String value; + + public String getValue() { + return value; + } + + public MultipleManagedDependentNoDiscriminatorSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentSameTypeNoDiscriminatorReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentSameTypeNoDiscriminatorReconciler.java new file mode 100644 index 0000000000..488ab8a771 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipledrsametypenodiscriminator/MultipleManagedDependentSameTypeNoDiscriminatorReconciler.java @@ -0,0 +1,64 @@ +package io.javaoperatorsdk.operator.dependent.multipledrsametypenodiscriminator; + +import java.util.List; +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.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +import static io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype.MultipleManagedDependentResourceReconciler.CONFIG_MAP_EVENT_SOURCE; + +@Workflow( + dependents = { + @Dependent( + type = MultipleManagedDependentNoDiscriminatorConfigMap1.class, + useEventSourceWithName = CONFIG_MAP_EVENT_SOURCE), + @Dependent( + type = MultipleManagedDependentNoDiscriminatorConfigMap2.class, + useEventSourceWithName = CONFIG_MAP_EVENT_SOURCE) + }) +@ControllerConfiguration +public class MultipleManagedDependentSameTypeNoDiscriminatorReconciler + implements Reconciler, + TestExecutionInfoProvider { + + public static final String CONFIG_MAP_EVENT_SOURCE = "ConfigMapEventSource"; + public static final String DATA_KEY = "key"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + public MultipleManagedDependentSameTypeNoDiscriminatorReconciler() {} + + @Override + public UpdateControl reconcile( + MultipleManagedDependentNoDiscriminatorCustomResource resource, + Context context) { + numberOfExecutions.getAndIncrement(); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> + prepareEventSources( + EventSourceContext context) { + InformerEventSource ies = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, MultipleManagedDependentNoDiscriminatorCustomResource.class) + .withName(CONFIG_MAP_EVENT_SOURCE) + .build(), + context); + + return List.of(ies); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceConfigMap1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceConfigMap1.java new file mode 100644 index 0000000000..687bfcb5ac --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceConfigMap1.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent +public class MultipleManagedDependentResourceConfigMap1 + extends CRUDKubernetesDependentResource< + ConfigMap, MultipleManagedDependentResourceCustomResource> { + + public static final String NAME_SUFFIX = "-1"; + + @Override + protected ConfigMap desired( + MultipleManagedDependentResourceCustomResource primary, + Context context) { + Map data = new HashMap<>(); + data.put(MultipleManagedDependentResourceReconciler.DATA_KEY, primary.getSpec().getValue()); + + return new ConfigMapBuilder() + .withNewMetadata() + .withName(primary.getMetadata().getName() + NAME_SUFFIX) + .withNamespace(primary.getMetadata().getNamespace()) + .endMetadata() + .withData(data) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceConfigMap2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceConfigMap2.java new file mode 100644 index 0000000000..1a1d6b51c0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceConfigMap2.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent +public class MultipleManagedDependentResourceConfigMap2 + extends CRUDKubernetesDependentResource< + ConfigMap, MultipleManagedDependentResourceCustomResource> { + + public static final String NAME_SUFFIX = "-2"; + + @Override + protected ConfigMap desired( + MultipleManagedDependentResourceCustomResource primary, + Context context) { + Map data = new HashMap<>(); + data.put(MultipleManagedDependentResourceReconciler.DATA_KEY, primary.getSpec().getValue()); + + return new ConfigMapBuilder() + .withNewMetadata() + .withName(primary.getMetadata().getName() + NAME_SUFFIX) + .withNamespace(primary.getMetadata().getNamespace()) + .endMetadata() + .withData(data) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceCustomResource.java new file mode 100644 index 0000000000..9daf1af59d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype; + +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("mmd") +public class MultipleManagedDependentResourceCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceReconciler.java new file mode 100644 index 0000000000..b2b90825b6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceReconciler.java @@ -0,0 +1,62 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype; + +import java.util.List; +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.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +import static io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype.MultipleManagedDependentResourceReconciler.CONFIG_MAP_EVENT_SOURCE; + +@Workflow( + dependents = { + @Dependent( + type = MultipleManagedDependentResourceConfigMap1.class, + useEventSourceWithName = CONFIG_MAP_EVENT_SOURCE), + @Dependent( + type = MultipleManagedDependentResourceConfigMap2.class, + useEventSourceWithName = CONFIG_MAP_EVENT_SOURCE) + }) +@ControllerConfiguration +public class MultipleManagedDependentResourceReconciler + implements Reconciler, + TestExecutionInfoProvider { + + public static final String CONFIG_MAP_EVENT_SOURCE = "ConfigMapEventSource"; + public static final String DATA_KEY = "key"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + public MultipleManagedDependentResourceReconciler() {} + + @Override + public UpdateControl reconcile( + MultipleManagedDependentResourceCustomResource resource, + Context context) { + numberOfExecutions.getAndIncrement(); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + InformerEventSource ies = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, MultipleManagedDependentResourceCustomResource.class) + .withName(CONFIG_MAP_EVENT_SOURCE) + .build(), + context); + return List.of(ies); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceSpec.java new file mode 100644 index 0000000000..9b50de6ad1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentResourceSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype; + +public class MultipleManagedDependentResourceSpec { + + private String value; + + public String getValue() { + return value; + } + + public MultipleManagedDependentResourceSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentSameTypeIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentSameTypeIT.java new file mode 100644 index 0000000000..a835b5a255 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanageddependentsametype/MultipleManagedDependentSameTypeIT.java @@ -0,0 +1,90 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype; + +import java.time.Duration; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.IntegrationTestConstants.GARBAGE_COLLECTION_TIMEOUT_SECONDS; +import static io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype.MultipleManagedDependentResourceReconciler.DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class MultipleManagedDependentSameTypeIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String DEFAULT_SPEC_VALUE = "val"; + public static final String UPDATED_SPEC_VALUE = "updated-val"; + public static final int SECONDS = 30; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new MultipleManagedDependentResourceReconciler()) + .build(); + + @Test + void handlesCrudOperations() { + operator.create(testResource()); + assertConfigMapsPresent(DEFAULT_SPEC_VALUE); + + var updatedResource = testResource(); + updatedResource.getSpec().setValue(UPDATED_SPEC_VALUE); + operator.replace(updatedResource); + assertConfigMapsPresent(UPDATED_SPEC_VALUE); + + operator.delete(testResource()); + assertConfigMapsDeleted(); + } + + private void assertConfigMapsPresent(String expectedData) { + await() + .untilAsserted( + () -> { + var maps = + operator + .getKubernetesClient() + .configMaps() + .inNamespace(operator.getNamespace()) + .list() + .getItems() + .stream() + .filter(cm -> cm.getMetadata().getName().startsWith(TEST_RESOURCE_NAME)) + .collect(Collectors.toList()); + assertThat(maps).hasSize(2); + assertThat(maps).allMatch(cm -> cm.getData().get(DATA_KEY).equals(expectedData)); + }); + } + + private void assertConfigMapsDeleted() { + await() + .atMost(Duration.ofSeconds(GARBAGE_COLLECTION_TIMEOUT_SECONDS)) + .untilAsserted( + () -> { + var maps = + operator + .getKubernetesClient() + .configMaps() + .inNamespace(operator.getNamespace()) + .list() + .getItems() + .stream() + .filter(cm -> cm.getMetadata().getName().startsWith(TEST_RESOURCE_NAME)) + .collect(Collectors.toList()); + assertThat(maps).hasSize(0); + }); + } + + private MultipleManagedDependentResourceCustomResource testResource() { + var res = new MultipleManagedDependentResourceCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + + res.setSpec(new MultipleManagedDependentResourceSpec()); + res.getSpec().setValue(DEFAULT_SPEC_VALUE); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/AbstractExternalDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/AbstractExternalDependentResource.java new file mode 100644 index 0000000000..710297ae68 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/AbstractExternalDependentResource.java @@ -0,0 +1,79 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanagedexternaldependenttype; + +import java.util.Map; +import java.util.Set; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Matcher; +import io.javaoperatorsdk.operator.processing.dependent.Updater; +import io.javaoperatorsdk.operator.processing.dependent.external.PollingDependentResource; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.support.ExternalResource; +import io.javaoperatorsdk.operator.support.ExternalServiceMock; + +public abstract class AbstractExternalDependentResource + extends PollingDependentResource< + ExternalResource, MultipleManagedExternalDependentResourceCustomResource> + implements Creator, + Updater, + Deleter { + + protected ExternalServiceMock externalServiceMock = ExternalServiceMock.getInstance(); + + public AbstractExternalDependentResource() { + super(ExternalResource.class, ExternalResource::getId); + } + + @Override + public Map> fetchResources() { + throw new IllegalStateException("Should not be called"); + } + + @Override + public ExternalResource create( + ExternalResource desired, + MultipleManagedExternalDependentResourceCustomResource primary, + Context context) { + return externalServiceMock.create(desired); + } + + @Override + public ExternalResource update( + ExternalResource actual, + ExternalResource desired, + MultipleManagedExternalDependentResourceCustomResource primary, + Context context) { + return externalServiceMock.update(desired); + } + + @Override + public Matcher.Result match( + ExternalResource actualResource, + MultipleManagedExternalDependentResourceCustomResource primary, + Context context) { + var desired = desired(primary, context); + return Matcher.Result.computed(actualResource.equals(desired), desired); + } + + @Override + public void delete( + MultipleManagedExternalDependentResourceCustomResource primary, + Context context) { + externalServiceMock.delete(toExternalResourceID(primary)); + } + + protected ExternalResource desired( + MultipleManagedExternalDependentResourceCustomResource primary, + Context context) { + return new ExternalResource(toExternalResourceID(primary), primary.getSpec().getValue()); + } + + protected String toExternalResourceID( + MultipleManagedExternalDependentResourceCustomResource primary) { + return ExternalResource.toExternalResourceId(primary) + resourceIDSuffix(); + } + + protected abstract String resourceIDSuffix(); +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/ExternalDependentResource1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/ExternalDependentResource1.java new file mode 100644 index 0000000000..d3894f797d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/ExternalDependentResource1.java @@ -0,0 +1,11 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanagedexternaldependenttype; + +public class ExternalDependentResource1 extends AbstractExternalDependentResource { + + public static final String SUFFIX = "-1"; + + @Override + protected String resourceIDSuffix() { + return SUFFIX; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/ExternalDependentResource2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/ExternalDependentResource2.java new file mode 100644 index 0000000000..9281bf3a9a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/ExternalDependentResource2.java @@ -0,0 +1,11 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanagedexternaldependenttype; + +public class ExternalDependentResource2 extends AbstractExternalDependentResource { + + public static final String SUFFIX = "-2"; + + @Override + protected String resourceIDSuffix() { + return SUFFIX; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/MultipleManagedExternalDependentResourceCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/MultipleManagedExternalDependentResourceCustomResource.java new file mode 100644 index 0000000000..a3b1693ab2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/MultipleManagedExternalDependentResourceCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanagedexternaldependenttype; + +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; +import io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype.MultipleManagedDependentResourceSpec; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("mme") +public class MultipleManagedExternalDependentResourceCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/MultipleManagedExternalDependentResourceReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/MultipleManagedExternalDependentResourceReconciler.java new file mode 100644 index 0000000000..5834985e57 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/MultipleManagedExternalDependentResourceReconciler.java @@ -0,0 +1,90 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanagedexternaldependenttype; + +import java.time.Duration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +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.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.polling.PollingConfigurationBuilder; +import io.javaoperatorsdk.operator.processing.event.source.polling.PollingEventSource; +import io.javaoperatorsdk.operator.support.ExternalResource; +import io.javaoperatorsdk.operator.support.ExternalServiceMock; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +import static io.javaoperatorsdk.operator.dependent.multiplemanagedexternaldependenttype.MultipleManagedExternalDependentResourceReconciler.EVENT_SOURCE_NAME; + +@Workflow( + dependents = { + @Dependent( + type = ExternalDependentResource1.class, + useEventSourceWithName = EVENT_SOURCE_NAME), + @Dependent( + type = ExternalDependentResource2.class, + useEventSourceWithName = EVENT_SOURCE_NAME) + }) +@ControllerConfiguration() +public class MultipleManagedExternalDependentResourceReconciler + implements Reconciler, + TestExecutionInfoProvider { + + public static final String EVENT_SOURCE_NAME = "ConfigMapEventSource"; + protected ExternalServiceMock externalServiceMock = ExternalServiceMock.getInstance(); + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + public MultipleManagedExternalDependentResourceReconciler() {} + + @Override + public UpdateControl reconcile( + MultipleManagedExternalDependentResourceCustomResource resource, + Context context) { + numberOfExecutions.getAndIncrement(); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + @Override + public List> + prepareEventSources( + EventSourceContext context) { + + final PollingEventSource.GenericResourceFetcher fetcher = + () -> { + var lists = externalServiceMock.listResources(); + final Map> res = new HashMap<>(); + lists.forEach( + er -> { + var resourceId = er.toResourceID(); + res.computeIfAbsent(resourceId, rid -> new HashSet<>()); + res.get(resourceId).add(er); + }); + return res; + }; + + PollingEventSource + pollingEventSource = + new PollingEventSource<>( + ExternalResource.class, + new PollingConfigurationBuilder<>(fetcher, Duration.ofMillis(1000L)) + .withName(EVENT_SOURCE_NAME) + .withCacheKeyMapper(ExternalResource::getId) + .build()); + + return List.of(pollingEventSource); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/MultipleManagedExternalDependentSameTypeIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/MultipleManagedExternalDependentSameTypeIT.java new file mode 100644 index 0000000000..a8c1f889d0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multiplemanagedexternaldependenttype/MultipleManagedExternalDependentSameTypeIT.java @@ -0,0 +1,69 @@ +package io.javaoperatorsdk.operator.dependent.multiplemanagedexternaldependenttype; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.dependent.multiplemanageddependentsametype.MultipleManagedDependentResourceSpec; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.support.ExternalServiceMock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class MultipleManagedExternalDependentSameTypeIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new MultipleManagedExternalDependentResourceReconciler()) + .build(); + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String DEFAULT_SPEC_VALUE = "val"; + public static final String UPDATED_SPEC_VALUE = "updated-val"; + + protected ExternalServiceMock externalServiceMock = ExternalServiceMock.getInstance(); + + @Test + void handlesExternalCrudOperations() { + operator.create(testResource()); + assertResourceCreatedWithData(DEFAULT_SPEC_VALUE); + + var updatedResource = testResource(); + updatedResource.getSpec().setValue(UPDATED_SPEC_VALUE); + operator.replace(updatedResource); + assertResourceCreatedWithData(UPDATED_SPEC_VALUE); + + operator.delete(testResource()); + assertExternalResourceDeleted(); + } + + private void assertExternalResourceDeleted() { + await() + .untilAsserted( + () -> { + var resources = externalServiceMock.listResources(); + assertThat(resources).hasSize(0); + }); + } + + private void assertResourceCreatedWithData(String expectedData) { + await() + .untilAsserted( + () -> { + var resources = externalServiceMock.listResources(); + assertThat(resources).hasSize(2); + assertThat(resources).allMatch(er -> er.getData().equals(expectedData)); + }); + } + + private MultipleManagedExternalDependentResourceCustomResource testResource() { + var res = new MultipleManagedExternalDependentResourceCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + + res.setSpec(new MultipleManagedDependentResourceSpec()); + res.getSpec().setValue(DEFAULT_SPEC_VALUE); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultiOwnerDependentTriggeringIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultiOwnerDependentTriggeringIT.java new file mode 100644 index 0000000000..d5a704ca0c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultiOwnerDependentTriggeringIT.java @@ -0,0 +1,83 @@ +package io.javaoperatorsdk.operator.dependent.multipleupdateondependent; + +import java.util.Set; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class MultiOwnerDependentTriggeringIT { + + public static final String VALUE_1 = "value1"; + public static final String VALUE_2 = "value2"; + public static final String NEW_VALUE_1 = "newValue1"; + public static final String NEW_VALUE_2 = "newValue2"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withConfigurationService(o -> o.withDefaultNonSSAResource(Set.of())) + .withReconciler(MultipleOwnerDependentReconciler.class) + .build(); + + @Test + void multiOwnerTriggeringAndManagement() { + var res1 = extension.create(testResource("res1", VALUE_1)); + var res2 = extension.create(testResource("res2", VALUE_2)); + + await() + .untilAsserted( + () -> { + var cm = + extension.get(ConfigMap.class, MultipleOwnerDependentConfigMap.RESOURCE_NAME); + + assertThat(cm).isNotNull(); + assertThat(cm.getData()) + .containsEntry(VALUE_1, VALUE_1) + .containsEntry(VALUE_2, VALUE_2); + assertThat(cm.getMetadata().getOwnerReferences()).hasSize(2); + }); + + res1.getSpec().setValue(NEW_VALUE_1); + extension.replace(res1); + + await() + .untilAsserted( + () -> { + var cm = + extension.get(ConfigMap.class, MultipleOwnerDependentConfigMap.RESOURCE_NAME); + assertThat(cm.getData()) + .containsEntry(NEW_VALUE_1, NEW_VALUE_1) + // note that it will still contain the old value too + .containsEntry(VALUE_1, VALUE_1); + assertThat(cm.getMetadata().getOwnerReferences()).hasSize(2); + }); + + res2.getSpec().setValue(NEW_VALUE_2); + extension.replace(res2); + + await() + .untilAsserted( + () -> { + var cm = + extension.get(ConfigMap.class, MultipleOwnerDependentConfigMap.RESOURCE_NAME); + assertThat(cm.getData()).containsEntry(NEW_VALUE_2, NEW_VALUE_2); + assertThat(cm.getMetadata().getOwnerReferences()).hasSize(2); + }); + } + + MultipleOwnerDependentCustomResource testResource(String name, String value) { + var res = new MultipleOwnerDependentCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(name).build()); + res.setSpec(new MultipleOwnerDependentSpec()); + res.getSpec().setValue(value); + + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentConfigMap.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentConfigMap.java new file mode 100644 index 0000000000..28ddfcc907 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentConfigMap.java @@ -0,0 +1,52 @@ +package io.javaoperatorsdk.operator.dependent.multipleupdateondependent; + +import java.util.HashMap; +import java.util.List; +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.BooleanWithUndefined; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@KubernetesDependent(useSSA = BooleanWithUndefined.TRUE) +public class MultipleOwnerDependentConfigMap + extends CRUDKubernetesDependentResource { + + public static final String RESOURCE_NAME = "test1"; + + @Override + protected ConfigMap desired( + MultipleOwnerDependentCustomResource primary, + Context context) { + + var cm = getSecondaryResource(primary, context); + + var data = cm.map(ConfigMap::getData).orElse(new HashMap<>()); + data.put(primary.getSpec().getValue(), primary.getSpec().getValue()); + + return new ConfigMapBuilder() + .withNewMetadata() + .withName(RESOURCE_NAME) + .withNamespace(primary.getMetadata().getNamespace()) + .withOwnerReferences(cm.map(c -> c.getMetadata().getOwnerReferences()).orElse(List.of())) + .endMetadata() + .withData(data) + .build(); + } + + // need to change this since owner reference is present only for the creator primary resource. + @Override + public Optional getSecondaryResource( + MultipleOwnerDependentCustomResource primary, + Context context) { + InformerEventSource ies = + (InformerEventSource) + context.eventSourceRetriever().getEventSourceFor(ConfigMap.class); + return ies.get(new ResourceID(RESOURCE_NAME, primary.getMetadata().getNamespace())); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentCustomResource.java new file mode 100644 index 0000000000..5f50d14d8e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.multipleupdateondependent; + +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("mod") +public class MultipleOwnerDependentCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentReconciler.java new file mode 100644 index 0000000000..b0fd098743 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentReconciler.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.dependent.multipleupdateondependent; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow(dependents = {@Dependent(type = MultipleOwnerDependentConfigMap.class)}) +@ControllerConfiguration() +public class MultipleOwnerDependentReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + public MultipleOwnerDependentReconciler() {} + + @Override + public UpdateControl reconcile( + MultipleOwnerDependentCustomResource resource, + Context context) { + numberOfExecutions.getAndIncrement(); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentSpec.java new file mode 100644 index 0000000000..5600dde5a4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/multipleupdateondependent/MultipleOwnerDependentSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.multipleupdateondependent; + +public class MultipleOwnerDependentSpec { + + private String value; + + public String getValue() { + return value; + } + + public MultipleOwnerDependentSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/DeploymentDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/DeploymentDependent.java new file mode 100644 index 0000000000..5cfb66f67e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/DeploymentDependent.java @@ -0,0 +1,95 @@ +package io.javaoperatorsdk.operator.dependent.prevblocklist; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ContainerBuilder; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.LabelSelectorBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.PodSpecBuilder; +import io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder; +import io.fabric8.kubernetes.api.model.Quantity; +import io.fabric8.kubernetes.api.model.ResourceRequirementsBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; +import io.fabric8.kubernetes.api.model.apps.DeploymentSpecBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.SSABasedGenericKubernetesResourceMatcher; + +@KubernetesDependent +public class DeploymentDependent + extends CRUDKubernetesDependentResource { + + public static final String RESOURCE_NAME = "test1"; + + public DeploymentDependent() { + super(Deployment.class); + } + + @Override + protected Deployment desired( + PrevAnnotationBlockCustomResource primary, + Context context) { + + return new DeploymentBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withSpec( + new DeploymentSpecBuilder() + .withReplicas(1) + .withSelector( + new LabelSelectorBuilder().withMatchLabels(Map.of("app", "nginx")).build()) + .withTemplate( + new PodTemplateSpecBuilder() + .withMetadata( + new ObjectMetaBuilder().withLabels(Map.of("app", "nginx")).build()) + .withSpec( + new PodSpecBuilder() + .withContainers( + new ContainerBuilder() + .withName("nginx") + .withImage("nginx:1.14.2") + .withResources( + new ResourceRequirementsBuilder() + .withLimits(Map.of("cpu", new Quantity("2000m"))) + .build()) + .build()) + .build()) + .build()) + .build()) + .build(); + } + + // for testing purposes replicating the matching logic but with the special matcher + @Override + public Result match( + Deployment actualResource, + Deployment desired, + PrevAnnotationBlockCustomResource primary, + Context context) { + final boolean matches; + addMetadata(true, actualResource, desired, primary, context); + if (useSSA(context)) { + matches = new SSAMatcherWithoutSanitization().matches(actualResource, desired, context); + } else { + matches = + GenericKubernetesResourceMatcher.match(desired, actualResource, false, false, context) + .matched(); + } + return Result.computed(matches, desired); + } + + // using this matcher, so we are able to reproduce issue with resource limits + static class SSAMatcherWithoutSanitization + extends SSABasedGenericKubernetesResourceMatcher { + + @Override + protected void sanitizeState(R actual, R desired, Map actualMap) {} + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockCustomResource.java new file mode 100644 index 0000000000..7aa3194672 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.prevblocklist; + +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("pabc") +public class PrevAnnotationBlockCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockReconciler.java new file mode 100644 index 0000000000..7f3dab61fe --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockReconciler.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.dependent.prevblocklist; + +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.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow(dependents = {@Dependent(type = DeploymentDependent.class)}) +@ControllerConfiguration() +public class PrevAnnotationBlockReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + public PrevAnnotationBlockReconciler() {} + + @Override + public UpdateControl reconcile( + PrevAnnotationBlockCustomResource resource, + Context context) { + numberOfExecutions.getAndIncrement(); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockReconcilerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockReconcilerIT.java new file mode 100644 index 0000000000..137e2ba663 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockReconcilerIT.java @@ -0,0 +1,50 @@ +package io.javaoperatorsdk.operator.dependent.prevblocklist; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class PrevAnnotationBlockReconcilerIT { + + public static final String TEST_1 = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + // Removing resource from blocklist List would result in test failure + // .withConfigurationService( + // o -> o.previousAnnotationUsageBlocklist(Collections.emptyList())) + .withReconciler(PrevAnnotationBlockReconciler.class) + .build(); + + @Test + void doNotUsePrevAnnotationForDeploymentDependent() { + extension.create(testResource(TEST_1)); + + var reconciler = extension.getReconcilerOfType(PrevAnnotationBlockReconciler.class); + await() + .pollDelay(Duration.ofMillis(200)) + .untilAsserted( + () -> { + var deployment = extension.get(Deployment.class, TEST_1); + assertThat(deployment).isNotNull(); + assertThat(reconciler.getNumberOfExecutions()).isGreaterThan(0).isLessThan(10); + }); + } + + PrevAnnotationBlockCustomResource testResource(String name) { + var res = new PrevAnnotationBlockCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(name).build()); + res.setSpec(new PrevAnnotationBlockSpec()); + res.getSpec().setValue("value"); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockSpec.java new file mode 100644 index 0000000000..9d80e14bc1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/prevblocklist/PrevAnnotationBlockSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.prevblocklist; + +public class PrevAnnotationBlockSpec { + + private String value; + + public String getValue() { + return value; + } + + public PrevAnnotationBlockSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primaryindexer/DependentPrimaryIndexerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primaryindexer/DependentPrimaryIndexerIT.java new file mode 100644 index 0000000000..16bb0d46a7 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primaryindexer/DependentPrimaryIndexerIT.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.primaryindexer; + +import io.javaoperatorsdk.operator.baseapi.primaryindexer.PrimaryIndexerIT; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +public class DependentPrimaryIndexerIT extends PrimaryIndexerIT { + + protected LocallyRunOperatorExtension buildOperator() { + return LocallyRunOperatorExtension.builder() + .withReconciler(new DependentPrimaryIndexerTestReconciler()) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primaryindexer/DependentPrimaryIndexerTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primaryindexer/DependentPrimaryIndexerTestReconciler.java new file mode 100644 index 0000000000..52094972da --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primaryindexer/DependentPrimaryIndexerTestReconciler.java @@ -0,0 +1,77 @@ +package io.javaoperatorsdk.operator.dependent.primaryindexer; + +import java.util.List; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.baseapi.primaryindexer.AbstractPrimaryIndexerTestReconciler; +import io.javaoperatorsdk.operator.baseapi.primaryindexer.PrimaryIndexerTestCustomResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +import static io.javaoperatorsdk.operator.dependent.primaryindexer.DependentPrimaryIndexerTestReconciler.CONFIG_MAP_EVENT_SOURCE; + +@Workflow( + dependents = + @Dependent( + useEventSourceWithName = CONFIG_MAP_EVENT_SOURCE, + type = DependentPrimaryIndexerTestReconciler.ReadOnlyConfigMapDependent.class)) +@ControllerConfiguration +public class DependentPrimaryIndexerTestReconciler extends AbstractPrimaryIndexerTestReconciler + implements Reconciler { + + public static final String CONFIG_MAP_EVENT_SOURCE = "configMapEventSource"; + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + var cache = context.getPrimaryCache(); + cache.addIndexer(CONFIG_MAP_RELATION_INDEXER, indexer); + + InformerEventSource es = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, PrimaryIndexerTestCustomResource.class) + .withName(CONFIG_MAP_EVENT_SOURCE) + .withSecondaryToPrimaryMapper( + resource -> + cache + .byIndex(CONFIG_MAP_RELATION_INDEXER, resource.getMetadata().getName()) + .stream() + .map(ResourceID::fromResource) + .collect(Collectors.toSet())) + .build(), + context); + + return List.of(es); + } + + public static class ReadOnlyConfigMapDependent + extends KubernetesDependentResource { + + @Override + protected ConfigMap desired( + PrimaryIndexerTestCustomResource primary, + Context context) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(CONFIG_MAP_NAME) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .build(); + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/ConfigMapDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/ConfigMapDependent.java new file mode 100644 index 0000000000..2b63bbf6b1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/ConfigMapDependent.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.dependent.primarytosecondaydependent; + +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.processing.dependent.kubernetes.KubernetesDependentResource; + +public class ConfigMapDependent + extends KubernetesDependentResource { + + public static final String TEST_CONFIG_MAP_NAME = "testconfigmap"; + + @Override + protected ConfigMap desired( + PrimaryToSecondaryDependentCustomResource primary, + Context context) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(TEST_CONFIG_MAP_NAME) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/ConfigMapReconcilePrecondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/ConfigMapReconcilePrecondition.java new file mode 100644 index 0000000000..024c275653 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/ConfigMapReconcilePrecondition.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.dependent.primarytosecondaydependent; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class ConfigMapReconcilePrecondition + implements Condition { + + public static final String DO_NOT_RECONCILE = "doNotReconcile"; + + @Override + public boolean isMet( + DependentResource dependentResource, + PrimaryToSecondaryDependentCustomResource primary, + Context context) { + return dependentResource + .getSecondaryResource(primary, context) + .map( + cm -> { + var data = cm.getData().get(PrimaryToSecondaryDependentReconciler.DATA_KEY); + return data != null && !data.equals(DO_NOT_RECONCILE); + }) + .orElse(false); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentCustomResource.java new file mode 100644 index 0000000000..c4f16b3e57 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.primarytosecondaydependent; + +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("ptsd") +public class PrimaryToSecondaryDependentCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentIT.java new file mode 100644 index 0000000000..11a080a8bf --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentIT.java @@ -0,0 +1,72 @@ +package io.javaoperatorsdk.operator.dependent.primarytosecondaydependent; + +import java.time.Duration; +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.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.dependent.primarytosecondaydependent.ConfigMapDependent.TEST_CONFIG_MAP_NAME; +import static io.javaoperatorsdk.operator.dependent.primarytosecondaydependent.ConfigMapReconcilePrecondition.DO_NOT_RECONCILE; +import static io.javaoperatorsdk.operator.dependent.primarytosecondaydependent.PrimaryToSecondaryDependentReconciler.DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class PrimaryToSecondaryDependentIT { + + public static final String TEST_CR_NAME = "test1"; + public static final String TEST_DATA = "testData"; + public @RegisterExtension LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new PrimaryToSecondaryDependentReconciler()) + .build(); + + @Test + void testPrimaryToSecondaryInDependentResources() { + var reconciler = operator.getReconcilerOfType(PrimaryToSecondaryDependentReconciler.class); + var cm = operator.create(configMap(DO_NOT_RECONCILE)); + operator.create(testCustomResource()); + + await() + .pollDelay(Duration.ofMillis(250)) + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions()).isPositive(); + assertThat(operator.get(Secret.class, TEST_CR_NAME)).isNull(); + }); + + cm.setData(Map.of(DATA_KEY, TEST_DATA)); + var executions = reconciler.getNumberOfExecutions(); + operator.replace(cm); + + await() + .pollDelay(Duration.ofMillis(250)) + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions()).isGreaterThan(executions); + var secret = operator.get(Secret.class, TEST_CR_NAME); + assertThat(secret).isNotNull(); + assertThat(secret.getData().get(DATA_KEY)).isEqualTo(TEST_DATA); + }); + } + + PrimaryToSecondaryDependentCustomResource testCustomResource() { + var res = new PrimaryToSecondaryDependentCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_CR_NAME).build()); + res.setSpec(new PrimaryToSecondaryDependentSpec()); + res.getSpec().setConfigMapName(TEST_CONFIG_MAP_NAME); + return res; + } + + ConfigMap configMap(String data) { + var cm = new ConfigMap(); + cm.setMetadata(new ObjectMetaBuilder().withName(TEST_CONFIG_MAP_NAME).build()); + cm.setData(Map.of(DATA_KEY, data)); + return cm; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentReconciler.java new file mode 100644 index 0000000000..0ad691d4da --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentReconciler.java @@ -0,0 +1,117 @@ +package io.javaoperatorsdk.operator.dependent.primarytosecondaydependent; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.PrimaryToSecondaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +import static io.javaoperatorsdk.operator.dependent.primarytosecondaydependent.PrimaryToSecondaryDependentReconciler.CONFIG_MAP; +import static io.javaoperatorsdk.operator.dependent.primarytosecondaydependent.PrimaryToSecondaryDependentReconciler.CONFIG_MAP_EVENT_SOURCE; + +/** + * Sample showcases how it is possible to do a primary to secondary mapper for a dependent resource. + * Note that this is usually just used with read only resources. So it has limited usage, one reason + * to use it is to have nice condition on that resource within a workflow. + */ +@Workflow( + dependents = { + @Dependent( + type = ConfigMapDependent.class, + name = CONFIG_MAP, + reconcilePrecondition = ConfigMapReconcilePrecondition.class, + useEventSourceWithName = CONFIG_MAP_EVENT_SOURCE), + @Dependent(type = SecretDependent.class, dependsOn = CONFIG_MAP) + }) +@ControllerConfiguration() +public class PrimaryToSecondaryDependentReconciler + implements Reconciler, TestExecutionInfoProvider { + + public static final String DATA_KEY = "data"; + public static final String CONFIG_MAP = "ConfigMap"; + public static final String CONFIG_MAP_INDEX = "ConfigMapIndex"; + public static final String CONFIG_MAP_EVENT_SOURCE = "ConfigMapEventSource"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + PrimaryToSecondaryDependentCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + /** + * Creating an Event Source and setting it for the Dependent Resource. Since it is not possible to + * do this setup elegantly within the bounds of the KubernetesDependentResource API. However, this + * is quite a corner case; might be covered more out of the box in the future if there will be + * demand for it. + */ + @Override + public List> prepareEventSources( + EventSourceContext context) { + // there is no owner reference in the config map, but we still want to trigger reconciliation if + // the config map changes. So first we add an index which custom resource references the config + // map. + context + .getPrimaryCache() + .addIndexer( + CONFIG_MAP_INDEX, + (primary -> + List.of( + indexKey( + primary.getSpec().getConfigMapName(), + primary.getMetadata().getNamespace())))); + + var es = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, PrimaryToSecondaryDependentCustomResource.class) + .withName(CONFIG_MAP_EVENT_SOURCE) + // if there is a many-to-many relationship (thus no direct owner reference) + // PrimaryToSecondaryMapper needs to be added + .withPrimaryToSecondaryMapper( + (PrimaryToSecondaryMapper) + p -> + Set.of( + new ResourceID( + p.getSpec().getConfigMapName(), + p.getMetadata().getNamespace()))) + // the index is used to trigger reconciliation of related custom resources if config + // map + // changes + .withSecondaryToPrimaryMapper( + cm -> + context + .getPrimaryCache() + .byIndex( + CONFIG_MAP_INDEX, + indexKey( + cm.getMetadata().getName(), cm.getMetadata().getNamespace())) + .stream() + .map(ResourceID::fromResource) + .collect(Collectors.toSet())) + .build(), + context); + + return List.of(es); + } + + private String indexKey(String configMapName, String namespace) { + return configMapName + "#" + namespace; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentSpec.java new file mode 100644 index 0000000000..bf2773f571 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/PrimaryToSecondaryDependentSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.primarytosecondaydependent; + +public class PrimaryToSecondaryDependentSpec { + + private String configMapName; + + public String getConfigMapName() { + return configMapName; + } + + public PrimaryToSecondaryDependentSpec setConfigMapName(String configMapName) { + this.configMapName = configMapName; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/SecretDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/SecretDependent.java new file mode 100644 index 0000000000..6371f453d7 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/primarytosecondaydependent/SecretDependent.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.dependent.primarytosecondaydependent; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +import static io.javaoperatorsdk.operator.dependent.primarytosecondaydependent.PrimaryToSecondaryDependentReconciler.DATA_KEY; + +public class SecretDependent + extends CRUDKubernetesDependentResource { + + @Override + protected Secret desired( + PrimaryToSecondaryDependentCustomResource primary, + Context context) { + Secret secret = new Secret(); + secret.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + secret.setData( + Map.of( + DATA_KEY, + context.getSecondaryResource(ConfigMap.class).orElseThrow().getData().get(DATA_KEY))); + return secret; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/readonly/ConfigMapReader.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/readonly/ConfigMapReader.java new file mode 100644 index 0000000000..eac66ecd0f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/readonly/ConfigMapReader.java @@ -0,0 +1,10 @@ +package io.javaoperatorsdk.operator.dependent.readonly; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Version("v1") +@Group("josdk.io") +public class ConfigMapReader extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/readonly/ReadOnlyDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/readonly/ReadOnlyDependent.java new file mode 100644 index 0000000000..a6f0662948 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/readonly/ReadOnlyDependent.java @@ -0,0 +1,8 @@ +package io.javaoperatorsdk.operator.dependent.readonly; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; + +@KubernetesDependent +public class ReadOnlyDependent extends KubernetesDependentResource {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/ConfigMapDependentResource.java new file mode 100644 index 0000000000..358718b107 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/ConfigMapDependentResource.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.dependent.restart; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent(informer = @Informer(labelSelector = "app=restart-test")) +public class ConfigMapDependentResource + extends CRUDKubernetesDependentResource { + + public static final String DATA_KEY = "key"; + + @Override + protected ConfigMap desired( + RestartTestCustomResource primary, Context context) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withLabels(Map.of("app", "restart-test")) + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of(DATA_KEY, primary.getMetadata().getName())) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/OperatorRestartIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/OperatorRestartIT.java new file mode 100644 index 0000000000..b8adb562dd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/OperatorRestartIT.java @@ -0,0 +1,58 @@ +package io.javaoperatorsdk.operator.dependent.restart; + +import org.junit.jupiter.api.*; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class OperatorRestartIT { + + private static final Operator operator = new Operator(o -> o.withCloseClientOnStop(false)); + private static final RestartTestReconciler reconciler = new RestartTestReconciler(); + private static int reconcileNumberBeforeStop = 0; + + @BeforeAll + static void registerReconciler() { + LocallyRunOperatorExtension.applyCrd( + RestartTestCustomResource.class, operator.getKubernetesClient()); + operator.register(reconciler); + } + + @BeforeEach + void startOperator() { + operator.start(); + } + + @AfterEach + void stopOperator() { + operator.stop(); + } + + @Test + @Order(1) + void createResource() { + operator.getKubernetesClient().resource(testCustomResource()).createOrReplace(); + await().untilAsserted(() -> assertThat(reconciler.getNumberOfExecutions()).isGreaterThan(0)); + reconcileNumberBeforeStop = reconciler.getNumberOfExecutions(); + } + + @Test + @Order(2) + void reconcile() { + await() + .untilAsserted( + () -> + assertThat(reconciler.getNumberOfExecutions()) + .isGreaterThan(reconcileNumberBeforeStop)); + } + + RestartTestCustomResource testCustomResource() { + RestartTestCustomResource cr = new RestartTestCustomResource(); + cr.setMetadata(new ObjectMetaBuilder().withName("test1").build()); + return cr; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/RestartTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/RestartTestCustomResource.java new file mode 100644 index 0000000000..fd5c14360c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/RestartTestCustomResource.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.dependent.restart; + +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("rt") +public class RestartTestCustomResource extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/RestartTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/RestartTestReconciler.java new file mode 100644 index 0000000000..1eeb8aa144 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/restart/RestartTestReconciler.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.dependent.restart; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow(dependents = @Dependent(type = ConfigMapDependentResource.class)) +@ControllerConfiguration +public class RestartTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + RestartTestCustomResource resource, Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} 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 new file mode 100644 index 0000000000..56e34330e1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceDependentResource.java @@ -0,0 +1,70 @@ +package io.javaoperatorsdk.operator.dependent.servicestrictmatcher; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.Service; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.Matcher; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; + +@KubernetesDependent +public class ServiceDependentResource + extends CRUDKubernetesDependentResource { + + public static AtomicInteger updated = new AtomicInteger(0); + + @Override + protected Service desired( + ServiceStrictMatcherTestCustomResource primary, + Context context) { + Service service = + loadYaml( + Service.class, + ServiceStrictMatcherIT.class, + "/io/javaoperatorsdk/operator/service.yaml"); + service.getMetadata().setName(primary.getMetadata().getName()); + service.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + Map labels = new HashMap<>(); + labels.put("app", "deployment-name"); + service.getSpec().setSelector(labels); + return service; + } + + @Override + public Matcher.Result match( + Service actualResource, + ServiceStrictMatcherTestCustomResource primary, + Context context) { + return GenericKubernetesResourceMatcher.match( + this, + actualResource, + primary, + context, + false, + false, + "/spec/ports", + "/spec/clusterIP", + "/spec/clusterIPs", + "/spec/externalTrafficPolicy", + "/spec/internalTrafficPolicy", + "/spec/ipFamilies", + "/spec/ipFamilyPolicy", + "/spec/sessionAffinity"); + } + + @Override + public Service update( + Service actual, + Service desired, + ServiceStrictMatcherTestCustomResource primary, + Context context) { + updated.addAndGet(1); + return super.update(actual, desired, primary, context); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherIT.java new file mode 100644 index 0000000000..72edd4ae40 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherIT.java @@ -0,0 +1,60 @@ +package io.javaoperatorsdk.operator.dependent.servicestrictmatcher; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class ServiceStrictMatcherIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new ServiceStrictMatcherTestReconciler()) + .build(); + + @Test + void testTheMatchingDoesNoTTriggersFurtherUpdates() { + var resource = operator.create(testResource()); + + await() + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(ServiceStrictMatcherTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(1); + }); + + // make an update to spec to reconcile again + resource.getSpec().setValue(2); + operator.replace(resource); + + await() + .pollDelay(Duration.ofMillis(300)) + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(ServiceStrictMatcherTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(2); + assertThat(ServiceDependentResource.updated.get()).isZero(); + }); + } + + ServiceStrictMatcherTestCustomResource testResource() { + var res = new ServiceStrictMatcherTestCustomResource(); + res.setSpec(new ServiceStrictMatcherSpec()); + res.getSpec().setValue(1); + res.setMetadata(new ObjectMetaBuilder().withName("test1").build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherSpec.java new file mode 100644 index 0000000000..d8840d92ba --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.servicestrictmatcher; + +public class ServiceStrictMatcherSpec { + + private int value; + + public int getValue() { + return value; + } + + public ServiceStrictMatcherSpec setValue(int value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherTestCustomResource.java new file mode 100644 index 0000000000..ac12749d32 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherTestCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.servicestrictmatcher; + +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("ssm") +public class ServiceStrictMatcherTestCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherTestReconciler.java new file mode 100644 index 0000000000..12f18b5319 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/servicestrictmatcher/ServiceStrictMatcherTestReconciler.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.dependent.servicestrictmatcher; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow(dependents = {@Dependent(type = ServiceDependentResource.class)}) +@ControllerConfiguration +public class ServiceStrictMatcherTestReconciler + implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + ServiceStrictMatcherTestCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/ServiceAccountDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/ServiceAccountDependentResource.java new file mode 100644 index 0000000000..1a598992b5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/ServiceAccountDependentResource.java @@ -0,0 +1,28 @@ +package io.javaoperatorsdk.operator.dependent.specialresourcesdependent; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.ServiceAccount; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +import static io.javaoperatorsdk.operator.dependent.specialresourcesdependent.SpecialResourceSpec.INITIAL_VALUE; + +@KubernetesDependent +public class ServiceAccountDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected ServiceAccount desired( + SpecialResourceCustomResource primary, Context context) { + ServiceAccount res = new ServiceAccount(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + res.setAutomountServiceAccountToken(INITIAL_VALUE.equals(primary.getSpec().getValue())); + + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourceCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourceCustomResource.java new file mode 100644 index 0000000000..84553fb61a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourceCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.specialresourcesdependent; + +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("srd") +public class SpecialResourceCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourceSpec.java new file mode 100644 index 0000000000..f1596e0829 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourceSpec.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.dependent.specialresourcesdependent; + +public class SpecialResourceSpec { + + public static final String INITIAL_VALUE = "initial_val"; + public static final String CHANGED_VALUE = "changed_val"; + + private String value; + + public String getValue() { + return value; + } + + public SpecialResourceSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourceTestReconciler.java new file mode 100644 index 0000000000..bde90f7340 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourceTestReconciler.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.dependent.specialresourcesdependent; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow( + dependents = { + @Dependent(type = ServiceAccountDependentResource.class), + }) +@ControllerConfiguration(informer = @Informer(namespaces = Constants.WATCH_CURRENT_NAMESPACE)) +public class SpecialResourceTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + SpecialResourceCustomResource resource, Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourcesDependentIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourcesDependentIT.java new file mode 100644 index 0000000000..3d62512bd7 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/specialresourcesdependent/SpecialResourcesDependentIT.java @@ -0,0 +1,60 @@ +package io.javaoperatorsdk.operator.dependent.specialresourcesdependent; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.ServiceAccount; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.dependent.specialresourcesdependent.SpecialResourceSpec.CHANGED_VALUE; +import static io.javaoperatorsdk.operator.dependent.specialresourcesdependent.SpecialResourceSpec.INITIAL_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/* + * Test for resources that are somehow special, currently mostly to cover the approach to handle + * resources without spec. Not all the resources added here. + */ +public class SpecialResourcesDependentIT { + + public static final String RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new SpecialResourceTestReconciler()) + .build(); + + @Test + void specialCRUDReconciler() { + var resource = extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var sa = extension.get(ServiceAccount.class, RESOURCE_NAME); + assertThat(sa).isNotNull(); + assertThat(sa.getAutomountServiceAccountToken()).isTrue(); + }); + + resource.getSpec().setValue(CHANGED_VALUE); + extension.replace(resource); + + await() + .untilAsserted( + () -> { + var sa = extension.get(ServiceAccount.class, RESOURCE_NAME); + assertThat(sa).isNotNull(); + assertThat(sa.getAutomountServiceAccountToken()).isFalse(); + }); + } + + SpecialResourceCustomResource testResource() { + SpecialResourceCustomResource res = new SpecialResourceCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + res.setSpec(new SpecialResourceSpec()); + res.getSpec().setValue(INITIAL_VALUE); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSALegacyMatcherCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSALegacyMatcherCustomResource.java new file mode 100644 index 0000000000..dffcea0e93 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSALegacyMatcherCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.ssalegacymatcher; + +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("slm") +public class SSALegacyMatcherCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSALegacyMatcherReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSALegacyMatcherReconciler.java new file mode 100644 index 0000000000..29c97b1400 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSALegacyMatcherReconciler.java @@ -0,0 +1,24 @@ +package io.javaoperatorsdk.operator.dependent.ssalegacymatcher; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow(dependents = {@Dependent(type = ServiceDependentResource.class)}) +@ControllerConfiguration +public class SSALegacyMatcherReconciler implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + SSALegacyMatcherCustomResource resource, Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSALegacyMatcherSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSALegacyMatcherSpec.java new file mode 100644 index 0000000000..b2bee7f12d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSALegacyMatcherSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.ssalegacymatcher; + +public class SSALegacyMatcherSpec { + + private String value; + + public String getValue() { + return value; + } + + public SSALegacyMatcherSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSAWithLegacyMatcherIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSAWithLegacyMatcherIT.java new file mode 100644 index 0000000000..63afd73d1a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/SSAWithLegacyMatcherIT.java @@ -0,0 +1,51 @@ +package io.javaoperatorsdk.operator.dependent.ssalegacymatcher; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Service; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class SSAWithLegacyMatcherIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new SSALegacyMatcherReconciler()) + .build(); + + @Test + void matchesDependentWithLegacyMatcher() { + var resource = extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var service = extension.get(Service.class, TEST_RESOURCE_NAME); + assertThat(service).isNotNull(); + assertThat(ServiceDependentResource.createUpdateCount.get()).isEqualTo(1); + }); + + resource.getSpec().setValue("other_value"); + + await() + .untilAsserted( + () -> { + assertThat(ServiceDependentResource.createUpdateCount.get()).isEqualTo(1); + }); + } + + SSALegacyMatcherCustomResource testResource() { + SSALegacyMatcherCustomResource res = new SSALegacyMatcherCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + res.setSpec(new SSALegacyMatcherSpec()); + res.getSpec().setValue("initial-value"); + return res; + } +} 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 new file mode 100644 index 0000000000..f4007b6151 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/ssalegacymatcher/ServiceDependentResource.java @@ -0,0 +1,69 @@ +package io.javaoperatorsdk.operator.dependent.ssalegacymatcher; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.Service; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; + +@KubernetesDependent +public class ServiceDependentResource + extends CRUDKubernetesDependentResource { + + public static AtomicInteger createUpdateCount = new AtomicInteger(0); + + @Override + protected Service desired( + SSALegacyMatcherCustomResource primary, Context context) { + + Service service = + loadYaml( + Service.class, + SSAWithLegacyMatcherIT.class, + "/io/javaoperatorsdk/operator/service.yaml"); + service.getMetadata().setName(primary.getMetadata().getName()); + service.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + Map labels = new HashMap<>(); + labels.put("app", "deployment-name"); + service.getSpec().setSelector(labels); + return service; + } + + @Override + public Result match( + Service actualResource, + SSALegacyMatcherCustomResource primary, + Context context) { + var desired = desired(primary, context); + + return GenericKubernetesResourceMatcher.match( + this, actualResource, primary, context, false, false); + } + + // override just to check the exec count + @Override + public Service update( + Service actual, + Service desired, + SSALegacyMatcherCustomResource primary, + Context context) { + createUpdateCount.addAndGet(1); + return super.update(actual, desired, primary, context); + } + + // override just to check the exec count + @Override + public Service create( + Service desired, + SSALegacyMatcherCustomResource primary, + Context context) { + createUpdateCount.addAndGet(1); + return super.create(desired, primary, context); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java new file mode 100644 index 0000000000..91fbd64a19 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java @@ -0,0 +1,105 @@ +package io.javaoperatorsdk.operator.dependent.standalonedependent; + +import java.time.Duration; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.javaoperatorsdk.operator.api.config.*; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class StandaloneDependentResourceIT { + + public static final String DEPENDENT_TEST_NAME = "dependent-test1"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new StandaloneDependentTestReconciler()) + .build(); + + @Test + void dependentResourceManagesDeployment() { + StandaloneDependentTestCustomResource customResource = + new StandaloneDependentTestCustomResource(); + customResource.setSpec(new StandaloneDependentTestCustomResourceSpec()); + customResource.setMetadata(new ObjectMeta()); + customResource.getMetadata().setName(DEPENDENT_TEST_NAME); + + operator.create(customResource); + + awaitForDeploymentReadyReplicas(1); + assertThat( + ((StandaloneDependentTestReconciler) operator.getFirstReconciler()).isErrorOccurred()) + .isFalse(); + } + + @Test + void executeUpdateForTestingCacheUpdateForGetResource() { + StandaloneDependentTestCustomResource customResource = + new StandaloneDependentTestCustomResource(); + customResource.setSpec(new StandaloneDependentTestCustomResourceSpec()); + customResource.setMetadata(new ObjectMeta()); + customResource.getMetadata().setName(DEPENDENT_TEST_NAME); + var createdCR = operator.create(customResource); + + awaitForDeploymentReadyReplicas(1); + + var clonedCr = cloner().clone(createdCR); + clonedCr.getSpec().setReplicaCount(2); + operator.replace(clonedCr); + + awaitForDeploymentReadyReplicas(2); + assertThat( + ((StandaloneDependentTestReconciler) operator.getFirstReconciler()).isErrorOccurred()) + .isFalse(); + } + + void awaitForDeploymentReadyReplicas(int expectedReplicaCount) { + await() + .pollInterval(Duration.ofMillis(300)) + .atMost(Duration.ofSeconds(50)) + .until( + () -> { + var deployment = + operator + .getKubernetesClient() + .resources(Deployment.class) + .inNamespace(operator.getNamespace()) + .withName(DEPENDENT_TEST_NAME) + .get(); + return deployment != null + && deployment.getStatus() != null + && deployment.getStatus().getReadyReplicas() != null + && deployment.getStatus().getReadyReplicas() == expectedReplicaCount; + }); + } + + Cloner cloner() { + return new ConfigurationService() { + @Override + public ControllerConfiguration getConfigurationFor( + Reconciler reconciler) { + return null; + } + + @Override + public Set getKnownReconcilerNames() { + return null; + } + + @Override + public Version getVersion() { + return null; + } + }.getResourceCloner(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestCustomResource.java new file mode 100644 index 0000000000..dd966f5035 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.dependent.standalonedependent; + +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("sdt") +public class StandaloneDependentTestCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestCustomResourceSpec.java new file mode 100644 index 0000000000..664331bbaf --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestCustomResourceSpec.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.operator.dependent.standalonedependent; + +public class StandaloneDependentTestCustomResourceSpec { + + private int replicaCount; + + public StandaloneDependentTestCustomResourceSpec(int replicaCount) { + this.replicaCount = replicaCount; + } + + public StandaloneDependentTestCustomResourceSpec() { + this(1); + } + + public int getReplicaCount() { + return replicaCount; + } + + public StandaloneDependentTestCustomResourceSpec setReplicaCount(int replicaCount) { + this.replicaCount = replicaCount; + return this; + } +} 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 new file mode 100644 index 0000000000..f5d9571711 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentTestReconciler.java @@ -0,0 +1,88 @@ +package io.javaoperatorsdk.operator.dependent.standalonedependent; + +import java.util.List; +import java.util.Optional; + +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.javaoperatorsdk.operator.ReconcilerUtils; +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.EventSourceUtils; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; + +@ControllerConfiguration +public class StandaloneDependentTestReconciler + implements Reconciler { + private volatile boolean errorOccurred = false; + + DeploymentDependentResource deploymentDependent; + + public StandaloneDependentTestReconciler() { + deploymentDependent = new DeploymentDependentResource(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + return EventSourceUtils.dependentEventSources(context, deploymentDependent); + } + + @Override + public UpdateControl reconcile( + StandaloneDependentTestCustomResource primary, + Context context) { + deploymentDependent.reconcile(primary, context); + Optional deployment = context.getSecondaryResource(Deployment.class); + if (deployment.isEmpty()) { + throw new IllegalStateException("Resource should not be empty after reconcile."); + } + + if (deployment.get().getSpec().getReplicas() != primary.getSpec().getReplicaCount()) { + // see https://github.com/operator-framework/java-operator-sdk/issues/924 + throw new IllegalStateException("Something went wrong with the cache mechanism."); + } + return UpdateControl.noUpdate(); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + StandaloneDependentTestCustomResource resource, + Context context, + Exception e) { + // this can happen when a namespace is terminated in test + if (e instanceof KubernetesClientException) { + return ErrorStatusUpdateControl.noStatusUpdate(); + } + errorOccurred = true; + return ErrorStatusUpdateControl.noStatusUpdate(); + } + + public boolean isErrorOccurred() { + return errorOccurred; + } + + private static class DeploymentDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected Deployment desired( + StandaloneDependentTestCustomResource primary, + Context context) { + Deployment deployment = + ReconcilerUtils.loadYaml( + Deployment.class, + StandaloneDependentResourceIT.class, + "/io/javaoperatorsdk/operator/nginx-deployment.yaml"); + deployment.getMetadata().setName(primary.getMetadata().getName()); + deployment.getSpec().setReplicas(primary.getSpec().getReplicaCount()); + deployment.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + return deployment; + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerCustomResource.java new file mode 100644 index 0000000000..ebfac08b09 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerCustomResource.java @@ -0,0 +1,11 @@ +package io.javaoperatorsdk.operator.dependent.statefulsetdesiredsanitizer; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class StatefulSetDesiredSanitizerCustomResource + extends CustomResource implements Namespaced {} 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 new file mode 100644 index 0000000000..fb8e4a6880 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java @@ -0,0 +1,43 @@ +package io.javaoperatorsdk.operator.dependent.statefulsetdesiredsanitizer; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.apps.StatefulSet; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class StatefulSetDesiredSanitizerDependentResource + extends CRUDKubernetesDependentResource< + StatefulSet, StatefulSetDesiredSanitizerCustomResource> { + + public static volatile Boolean nonMatchedAtLeastOnce; + + @Override + protected StatefulSet desired( + StatefulSetDesiredSanitizerCustomResource primary, + Context context) { + var template = + ReconcilerUtils.loadYaml( + StatefulSet.class, getClass(), "/io/javaoperatorsdk/operator/statefulset.yaml"); + template.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + return template; + } + + @Override + public Result match( + StatefulSet actualResource, + StatefulSetDesiredSanitizerCustomResource primary, + Context context) { + var res = super.match(actualResource, primary, context); + if (!res.matched()) { + nonMatchedAtLeastOnce = true; + } else if (nonMatchedAtLeastOnce == null) { + nonMatchedAtLeastOnce = false; + } + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerIT.java new file mode 100644 index 0000000000..f54708a9ea --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerIT.java @@ -0,0 +1,55 @@ +package io.javaoperatorsdk.operator.dependent.statefulsetdesiredsanitizer; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.apps.StatefulSet; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class StatefulSetDesiredSanitizerIT { + + public static final String TEST_1 = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new StatefulSetDesiredSanitizerReconciler()) + .build(); + + @Test + void testSSAMatcher() { + var resource = extension.create(testResource()); + + await() + .pollDelay(Duration.ofMillis(200)) + .untilAsserted( + () -> { + var statefulSet = extension.get(StatefulSet.class, TEST_1); + assertThat(statefulSet).isNotNull(); + }); + // make sure reconciliation happens at least once more + resource.getSpec().setValue("changed value"); + extension.replace(resource); + + await() + .untilAsserted( + () -> + assertThat(StatefulSetDesiredSanitizerDependentResource.nonMatchedAtLeastOnce) + .isFalse()); + } + + StatefulSetDesiredSanitizerCustomResource testResource() { + var res = new StatefulSetDesiredSanitizerCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_1).build()); + res.setSpec(new StatefulSetDesiredSanitizerSpec()); + res.getSpec().setValue("initial value"); + + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerReconciler.java new file mode 100644 index 0000000000..284119ba61 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerReconciler.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.dependent.statefulsetdesiredsanitizer; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow(dependents = {@Dependent(type = StatefulSetDesiredSanitizerDependentResource.class)}) +@ControllerConfiguration +public class StatefulSetDesiredSanitizerReconciler + implements Reconciler { + + @Override + public UpdateControl reconcile( + StatefulSetDesiredSanitizerCustomResource resource, + Context context) + throws Exception { + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerSpec.java new file mode 100644 index 0000000000..59fd852097 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.dependent.statefulsetdesiredsanitizer; + +public class StatefulSetDesiredSanitizerSpec { + + private String value; + + public String getValue() { + return value; + } + + public StatefulSetDesiredSanitizerSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/ExternalIDGenServiceMock.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/ExternalIDGenServiceMock.java new file mode 100644 index 0000000000..df48fc282b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/ExternalIDGenServiceMock.java @@ -0,0 +1,41 @@ +package io.javaoperatorsdk.operator.support; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class ExternalIDGenServiceMock { + + private static final ExternalIDGenServiceMock serviceMock = new ExternalIDGenServiceMock(); + + private final Map resourceMap = new ConcurrentHashMap<>(); + + public ExternalResource create(ExternalResource externalResource) { + if (externalResource.getId() != null) { + throw new IllegalArgumentException("ID provided for external resource"); + } + String id = UUID.randomUUID().toString(); + var newResource = new ExternalResource(id, externalResource.getData()); + resourceMap.put(id, newResource); + return newResource; + } + + public Optional read(String id) { + return Optional.ofNullable(resourceMap.get(id)); + } + + public ExternalResource update(ExternalResource externalResource) { + return resourceMap.put(externalResource.getId(), externalResource); + } + + public Optional delete(String id) { + return Optional.ofNullable(resourceMap.remove(id)); + } + + public List listResources() { + return new ArrayList<>(resourceMap.values()); + } + + public static ExternalIDGenServiceMock getInstance() { + return serviceMock; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/ExternalResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/ExternalResource.java new file mode 100644 index 0000000000..048b1642c8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/ExternalResource.java @@ -0,0 +1,68 @@ +package io.javaoperatorsdk.operator.support; + +import java.util.Objects; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public class ExternalResource { + + public static final String EXTERNAL_RESOURCE_NAME_DELIMITER = "#"; + + private String id; + private final String data; + + /** + * For the case that ide is generated by server + * + * @param data to store + */ + public ExternalResource(String data) { + this.data = data; + } + + public ExternalResource(String id, String data) { + this.id = id; + this.data = data; + } + + public String getId() { + return id; + } + + public String getData() { + return data; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ExternalResource that = (ExternalResource) o; + return Objects.equals(id, that.id) && Objects.equals(data, that.data); + } + + @Override + public int hashCode() { + return Objects.hash(id, data); + } + + public ResourceID toResourceID() { + var parts = getId().split(EXTERNAL_RESOURCE_NAME_DELIMITER); + return new ResourceID(parts[0], parts[1]); + } + + public static String toExternalResourceId(HasMetadata primary, int i) { + return primary.getMetadata().getName() + + EXTERNAL_RESOURCE_NAME_DELIMITER + + primary.getMetadata().getNamespace() + + EXTERNAL_RESOURCE_NAME_DELIMITER + + i; + } + + public static String toExternalResourceId(HasMetadata primary) { + return primary.getMetadata().getName() + + EXTERNAL_RESOURCE_NAME_DELIMITER + + primary.getMetadata().getNamespace(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/ExternalServiceMock.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/ExternalServiceMock.java new file mode 100644 index 0000000000..08e723145f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/ExternalServiceMock.java @@ -0,0 +1,42 @@ +package io.javaoperatorsdk.operator.support; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class ExternalServiceMock { + + private static final ExternalServiceMock serviceMock = new ExternalServiceMock(); + + private final Map resourceMap = new ConcurrentHashMap<>(); + + public ExternalResource create(ExternalResource externalResource) { + if (externalResource.getId() == null) { + throw new IllegalArgumentException("id of the resource is null"); + } + resourceMap.put(externalResource.getId(), externalResource); + return externalResource; + } + + public Optional read(String id) { + return Optional.ofNullable(resourceMap.get(id)); + } + + public ExternalResource update(ExternalResource externalResource) { + return resourceMap.put(externalResource.getId(), externalResource); + } + + public Optional delete(String id) { + return Optional.ofNullable(resourceMap.remove(id)); + } + + public List listResources() { + return new ArrayList<>(resourceMap.values()); + } + + public static ExternalServiceMock getInstance() { + return serviceMock; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/TestExecutionInfoProvider.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/TestExecutionInfoProvider.java new file mode 100644 index 0000000000..f2f634041d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/TestExecutionInfoProvider.java @@ -0,0 +1,6 @@ +package io.javaoperatorsdk.operator.support; + +public interface TestExecutionInfoProvider { + + int getNumberOfExecutions(); +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/TestUtils.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/TestUtils.java new file mode 100644 index 0000000000..3d40690f09 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/TestUtils.java @@ -0,0 +1,60 @@ +package io.javaoperatorsdk.operator.support; + +import java.util.HashMap; +import java.util.UUID; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.baseapi.simple.TestCustomResource; +import io.javaoperatorsdk.operator.baseapi.simple.TestCustomResourceSpec; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +public class TestUtils { + + public static final String TEST_CUSTOM_RESOURCE_PREFIX = "test-custom-resource-"; + public static final String TEST_CUSTOM_RESOURCE_NAME = "test-custom-resource"; + + public static TestCustomResource testCustomResource() { + return testCustomResource(UUID.randomUUID().toString()); + } + + public static TestCustomResource testCustomResource(String uid) { + TestCustomResource resource = new TestCustomResource(); + resource.setMetadata( + new ObjectMetaBuilder() + .withName(TEST_CUSTOM_RESOURCE_NAME) + .withUid(uid) + .withGeneration(1L) + .build()); + resource.getMetadata().setAnnotations(new HashMap<>()); + resource.setKind("CustomService"); + resource.setSpec(new TestCustomResourceSpec()); + resource.getSpec().setConfigMapName("test-config-map"); + resource.getSpec().setKey("test-key"); + resource.getSpec().setValue("test-value"); + return resource; + } + + public static TestCustomResource testCustomResourceWithPrefix(String id) { + TestCustomResource resource = new TestCustomResource(); + resource.setMetadata( + new ObjectMetaBuilder().withName(TEST_CUSTOM_RESOURCE_PREFIX + id).build()); + resource.setKind("CustomService"); + resource.setSpec(new TestCustomResourceSpec()); + resource.getSpec().setConfigMapName("test-config-map-" + id); + resource.getSpec().setKey("test-key"); + resource.getSpec().setValue(id); + return resource; + } + + public static void waitXms(int x) { + try { + Thread.sleep(x); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + } + + public static int getNumberOfExecutions(LocallyRunOperatorExtension extension) { + return ((TestExecutionInfoProvider) extension.getReconcilers().get(0)).getNumberOfExecutions(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowCustomResource.java new file mode 100644 index 0000000000..dafc1fc497 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent; + +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("cdc") +public class ComplexWorkflowCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowIT.java new file mode 100644 index 0000000000..4af8467c5c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowIT.java @@ -0,0 +1,75 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.apps.StatefulSet; +import io.fabric8.kubernetes.client.readiness.Readiness; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.workflow.complexdependent.dependent.FirstService; +import io.javaoperatorsdk.operator.workflow.complexdependent.dependent.FirstStatefulSet; +import io.javaoperatorsdk.operator.workflow.complexdependent.dependent.SecondService; +import io.javaoperatorsdk.operator.workflow.complexdependent.dependent.SecondStatefulSet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class ComplexWorkflowIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(new ComplexWorkflowReconciler()).build(); + + @Test + void successfullyReconciles() { + operator.create(testResource()); + + await() + .atMost(Duration.ofSeconds(90)) + .untilAsserted( + () -> { + var res = operator.get(ComplexWorkflowCustomResource.class, TEST_RESOURCE_NAME); + assertThat(res.getStatus()).isNotNull(); + assertThat(res.getStatus().getStatus()) + .isEqualTo(ComplexWorkflowReconciler.RECONCILE_STATUS.READY); + }); + + var firstStatefulSet = + operator.get( + StatefulSet.class, + String.format("%s-%s", FirstStatefulSet.DISCRIMINATOR_PREFIX, TEST_RESOURCE_NAME)); + var secondStatefulSet = + operator.get( + StatefulSet.class, + String.format("%s-%s", SecondStatefulSet.DISCRIMINATOR_PREFIX, TEST_RESOURCE_NAME)); + var firstService = + operator.get( + Service.class, + String.format("%s-%s", FirstService.DISCRIMINATOR_PREFIX, TEST_RESOURCE_NAME)); + var secondService = + operator.get( + Service.class, + String.format("%s-%s", SecondService.DISCRIMINATOR_PREFIX, TEST_RESOURCE_NAME)); + assertThat(firstService).isNotNull(); + assertThat(secondService).isNotNull(); + assertThat(firstStatefulSet).isNotNull(); + assertThat(secondStatefulSet).isNotNull(); + assertThat(Readiness.isStatefulSetReady(firstStatefulSet)).isTrue(); + assertThat(Readiness.isStatefulSetReady(secondStatefulSet)).isTrue(); + } + + ComplexWorkflowCustomResource testResource() { + var resource = new ComplexWorkflowCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + resource.setSpec(new ComplexWorkflowSpec()); + resource.getSpec().setProjectId(TEST_RESOURCE_NAME); + + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowReconciler.java new file mode 100644 index 0000000000..952d4ec476 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowReconciler.java @@ -0,0 +1,93 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent; + +import java.util.List; +import java.util.Objects; + +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.apps.StatefulSet; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.workflow.complexdependent.dependent.FirstService; +import io.javaoperatorsdk.operator.workflow.complexdependent.dependent.FirstStatefulSet; +import io.javaoperatorsdk.operator.workflow.complexdependent.dependent.SecondService; +import io.javaoperatorsdk.operator.workflow.complexdependent.dependent.SecondStatefulSet; +import io.javaoperatorsdk.operator.workflow.complexdependent.dependent.StatefulSetReadyCondition; + +import static io.javaoperatorsdk.operator.workflow.complexdependent.ComplexWorkflowReconciler.SERVICE_EVENT_SOURCE_NAME; +import static io.javaoperatorsdk.operator.workflow.complexdependent.ComplexWorkflowReconciler.STATEFUL_SET_EVENT_SOURCE_NAME; + +@Workflow( + dependents = { + @Dependent( + name = "first-svc", + type = FirstService.class, + useEventSourceWithName = SERVICE_EVENT_SOURCE_NAME), + @Dependent( + name = "second-svc", + type = SecondService.class, + useEventSourceWithName = SERVICE_EVENT_SOURCE_NAME), + @Dependent( + name = "first", + type = FirstStatefulSet.class, + useEventSourceWithName = STATEFUL_SET_EVENT_SOURCE_NAME, + dependsOn = {"first-svc"}, + readyPostcondition = StatefulSetReadyCondition.class), + @Dependent( + name = "second", + type = SecondStatefulSet.class, + useEventSourceWithName = STATEFUL_SET_EVENT_SOURCE_NAME, + dependsOn = {"second-svc", "first"}, + readyPostcondition = StatefulSetReadyCondition.class), + }) +@ControllerConfiguration(name = "project-operator") +public class ComplexWorkflowReconciler implements Reconciler { + + public static final String SERVICE_EVENT_SOURCE_NAME = "serviceEventSource"; + public static final String STATEFUL_SET_EVENT_SOURCE_NAME = "statefulSetEventSource"; + + @Override + public UpdateControl reconcile( + ComplexWorkflowCustomResource resource, Context context) + throws Exception { + var ready = + context + .managedWorkflowAndDependentResourceContext() + .getWorkflowReconcileResult() + .orElseThrow() + .allDependentResourcesReady(); + + var status = Objects.requireNonNullElseGet(resource.getStatus(), ComplexWorkflowStatus::new); + status.setStatus(ready ? RECONCILE_STATUS.READY : RECONCILE_STATUS.NOT_READY); + resource.setStatus(status); + + return UpdateControl.patchStatus(resource); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + InformerEventSource serviceEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + Service.class, ComplexWorkflowCustomResource.class) + .withName(SERVICE_EVENT_SOURCE_NAME) + .build(), + context); + InformerEventSource statefulSetEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + StatefulSet.class, ComplexWorkflowCustomResource.class) + .withName(STATEFUL_SET_EVENT_SOURCE_NAME) + .build(), + context); + return List.of(serviceEventSource, statefulSetEventSource); + } + + public enum RECONCILE_STATUS { + READY, + NOT_READY + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowSpec.java new file mode 100644 index 0000000000..1e8f599c67 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent; + +public class ComplexWorkflowSpec { + private String projectId; + + public String getProjectId() { + return projectId; + } + + public ComplexWorkflowSpec setProjectId(String projectId) { + this.projectId = projectId; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowStatus.java new file mode 100644 index 0000000000..226880073f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/ComplexWorkflowStatus.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent; + +public class ComplexWorkflowStatus { + private ComplexWorkflowReconciler.RECONCILE_STATUS status; + + public ComplexWorkflowReconciler.RECONCILE_STATUS getStatus() { + return status; + } + + public ComplexWorkflowStatus setStatus(ComplexWorkflowReconciler.RECONCILE_STATUS status) { + this.status = status; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseDependentResource.java new file mode 100644 index 0000000000..e6bd8462cb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseDependentResource.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.workflow.complexdependent.ComplexWorkflowCustomResource; + +public abstract class BaseDependentResource + extends CRUDKubernetesDependentResource { + + public static final String K8S_NAME = "app.kubernetes.io/name"; + protected final String component; + + public BaseDependentResource(Class resourceType, String component) { + super(resourceType); + this.component = component; + } + + protected String name(ComplexWorkflowCustomResource primary) { + return String.format("%s-%s", component, primary.getSpec().getProjectId()); + } + + protected ObjectMetaBuilder createMeta(ComplexWorkflowCustomResource primary) { + String name = name(primary); + return new ObjectMetaBuilder() + .withName(name) + .withNamespace(primary.getMetadata().getNamespace()) + .addToLabels(K8S_NAME, name); + } +} 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 new file mode 100644 index 0000000000..3b57614dbc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseService.java @@ -0,0 +1,33 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent.dependent; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.ServiceBuilder; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.workflow.complexdependent.ComplexWorkflowCustomResource; + +public abstract class BaseService extends BaseDependentResource { + + public BaseService(String component) { + super(Service.class, component); + } + + @Override + protected Service desired( + ComplexWorkflowCustomResource primary, Context context) { + var template = + ReconcilerUtils.loadYaml( + Service.class, + getClass(), + "/io/javaoperatorsdk/operator/workflow/complexdependent/service.yaml"); + + return new ServiceBuilder(template) + .withMetadata(createMeta(primary).build()) + .editOrNewSpec() + .withSelector(Map.of(K8S_NAME, name(primary))) + .endSpec() + .build(); + } +} 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 new file mode 100644 index 0000000000..3847ec4c87 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/BaseStatefulSet.java @@ -0,0 +1,45 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent.dependent; + +import java.util.Map; + +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.api.reconciler.Context; +import io.javaoperatorsdk.operator.workflow.complexdependent.ComplexWorkflowCustomResource; + +public abstract class BaseStatefulSet extends BaseDependentResource { + public BaseStatefulSet(String component) { + super(StatefulSet.class, component); + } + + @Override + protected StatefulSet desired( + ComplexWorkflowCustomResource primary, Context context) { + var template = + ReconcilerUtils.loadYaml( + StatefulSet.class, + getClass(), + "/io/javaoperatorsdk/operator/workflow/complexdependent/statefulset.yaml"); + var name = name(primary); + var metadata = createMeta(primary).build(); + + return new StatefulSetBuilder(template) + .withMetadata(metadata) + .editSpec() + .withServiceName(name) + .editOrNewSelector() + .withMatchLabels(Map.of(K8S_NAME, name)) + .endSelector() + .editTemplate() + .withMetadata(metadata) + .endTemplate() + .editFirstVolumeClaimTemplate() + .editMetadata() + .withLabels(Map.of(K8S_NAME, name)) + .endMetadata() + .endVolumeClaimTemplate() + .endSpec() + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/FirstService.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/FirstService.java new file mode 100644 index 0000000000..57686c6f7d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/FirstService.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent.dependent; + +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent +public class FirstService extends BaseService { + public static final String DISCRIMINATOR_PREFIX = "first"; + + public FirstService() { + super(DISCRIMINATOR_PREFIX); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/FirstStatefulSet.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/FirstStatefulSet.java new file mode 100644 index 0000000000..4a065f7ca6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/FirstStatefulSet.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent.dependent; + +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent +public class FirstStatefulSet extends BaseStatefulSet { + + public static final String DISCRIMINATOR_PREFIX = "first"; + + public FirstStatefulSet() { + super(DISCRIMINATOR_PREFIX); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/SecondService.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/SecondService.java new file mode 100644 index 0000000000..1b361ced71 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/SecondService.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent.dependent; + +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent() +public class SecondService extends BaseService { + + public static final String DISCRIMINATOR_PREFIX = "second"; + + public SecondService() { + super(DISCRIMINATOR_PREFIX); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/SecondStatefulSet.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/SecondStatefulSet.java new file mode 100644 index 0000000000..85508b9695 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/SecondStatefulSet.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent.dependent; + +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent +public class SecondStatefulSet extends BaseStatefulSet { + + public static final String DISCRIMINATOR_PREFIX = "second"; + + public SecondStatefulSet() { + super(DISCRIMINATOR_PREFIX); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/StatefulSetReadyCondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/StatefulSetReadyCondition.java new file mode 100644 index 0000000000..59422941ac --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/complexdependent/dependent/StatefulSetReadyCondition.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.workflow.complexdependent.dependent; + +import io.fabric8.kubernetes.api.model.apps.StatefulSet; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; +import io.javaoperatorsdk.operator.workflow.complexdependent.ComplexWorkflowCustomResource; + +public class StatefulSetReadyCondition + implements Condition { + + @Override + public boolean isMet( + DependentResource dependentResource, + ComplexWorkflowCustomResource primary, + Context context) { + + return dependentResource + .getSecondaryResource(primary, context) + .map( + secondary -> { + var readyReplicas = secondary.getStatus().getReadyReplicas(); + return readyReplicas != null && readyReplicas > 0; + }) + .orElse(false); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationConditionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationConditionIT.java new file mode 100644 index 0000000000..1793cceefa --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationConditionIT.java @@ -0,0 +1,75 @@ +package io.javaoperatorsdk.operator.workflow.crdpresentactivation; + +import java.time.Duration; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class CRDPresentActivationConditionIT { + + public static final String TEST_1 = "test1"; + public static final String CRD_NAME = + "crdpresentactivationdependentcustomresources.sample.javaoperatorsdk"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new CRDPresentActivationReconciler()) + .build(); + + @Test + void resourceCreatedOnlyIfCRDPresent() { + // deleted so test can be repeated + extension + .getKubernetesClient() + .resources(CustomResourceDefinition.class) + .withName(CRD_NAME) + .delete(); + + var resource = extension.create(testResource()); + + await() + .pollDelay(Duration.ofMillis(300)) + .untilAsserted( + () -> { + var crd = + extension + .getKubernetesClient() + .resources(CustomResourceDefinition.class) + .withName(CRD_NAME) + .get(); + assertThat(crd).isNull(); + + var dr = extension.get(CRDPresentActivationDependentCustomResource.class, TEST_1); + assertThat(dr).isNull(); + }); + + LocallyRunOperatorExtension.applyCrd( + CRDPresentActivationDependentCustomResource.class, extension.getKubernetesClient()); + + resource.getMetadata().setAnnotations(Map.of("sample", "value")); + extension.replace(resource); + + await() + .pollDelay(Duration.ofMillis(300)) + .untilAsserted( + () -> { + var cm = extension.get(CRDPresentActivationDependentCustomResource.class, TEST_1); + assertThat(cm).isNull(); + }); + } + + CRDPresentActivationCustomResource testResource() { + var res = new CRDPresentActivationCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_1).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationCustomResource.java new file mode 100644 index 0000000000..a010f42cca --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.crdpresentactivation; + +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("crdp") +public class CRDPresentActivationCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationDependent.java new file mode 100644 index 0000000000..11923e274b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationDependent.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.operator.workflow.crdpresentactivation; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; + +public class CRDPresentActivationDependent + extends CRUDNoGCKubernetesDependentResource< + CRDPresentActivationDependentCustomResource, CRDPresentActivationCustomResource> { + + @Override + protected CRDPresentActivationDependentCustomResource desired( + CRDPresentActivationCustomResource primary, + Context context) { + var res = new CRDPresentActivationDependentCustomResource(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationDependentCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationDependentCustomResource.java new file mode 100644 index 0000000000..4be93ab453 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationDependentCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.crdpresentactivation; + +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("addp") +public class CRDPresentActivationDependentCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationReconciler.java new file mode 100644 index 0000000000..49aebd5e4f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/crdpresentactivation/CRDPresentActivationReconciler.java @@ -0,0 +1,33 @@ +package io.javaoperatorsdk.operator.workflow.crdpresentactivation; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.dependent.workflow.CRDPresentActivationCondition; + +@Workflow( + dependents = { + @Dependent( + type = CRDPresentActivationDependent.class, + activationCondition = CRDPresentActivationCondition.class), + }) +// to trigger reconciliation with metadata change +@ControllerConfiguration(generationAwareEventProcessing = false) +public class CRDPresentActivationReconciler + implements Reconciler, + Cleaner { + + @Override + public UpdateControl reconcile( + CRDPresentActivationCustomResource resource, + Context context) { + + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup( + CRDPresentActivationCustomResource resource, + Context context) { + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/ConfigMapDependentResource.java new file mode 100644 index 0000000000..c9078848b4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/ConfigMapDependentResource.java @@ -0,0 +1,25 @@ +package io.javaoperatorsdk.operator.workflow.getnonactivesecondary; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class ConfigMapDependentResource + extends CRUDKubernetesDependentResource { + + public static final String DATA_KEY = "data"; + + @Override + protected ConfigMap desired( + GetNonActiveSecondaryCustomResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/FalseActivationCondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/FalseActivationCondition.java new file mode 100644 index 0000000000..fde69215bc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/FalseActivationCondition.java @@ -0,0 +1,17 @@ +package io.javaoperatorsdk.operator.workflow.getnonactivesecondary; + +import io.fabric8.openshift.api.model.Route; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class FalseActivationCondition + implements Condition { + @Override + public boolean isMet( + DependentResource dependentResource, + GetNonActiveSecondaryCustomResource primary, + Context context) { + return false; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/GetNonActiveSecondaryCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/GetNonActiveSecondaryCustomResource.java new file mode 100644 index 0000000000..be12dec053 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/GetNonActiveSecondaryCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.getnonactivesecondary; + +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("gnas") +public class GetNonActiveSecondaryCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/RouteDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/RouteDependentResource.java new file mode 100644 index 0000000000..77ebef373a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/RouteDependentResource.java @@ -0,0 +1,25 @@ +package io.javaoperatorsdk.operator.workflow.getnonactivesecondary; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.openshift.api.model.Route; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class RouteDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected Route desired( + GetNonActiveSecondaryCustomResource primary, + Context context) { + // basically does not matter since this should not be called + Route route = new Route(); + route.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + + return route; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/WorkflowActivationConditionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/WorkflowActivationConditionIT.java new file mode 100644 index 0000000000..0de986ccf9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/WorkflowActivationConditionIT.java @@ -0,0 +1,42 @@ +package io.javaoperatorsdk.operator.workflow.getnonactivesecondary; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class WorkflowActivationConditionIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(WorkflowActivationConditionReconciler.class) + .build(); + + @Test + void reconciledOnVanillaKubernetesDespiteRouteInWorkflow() { + extension.create(testResource()); + + await() + .untilAsserted( + () -> { + assertThat( + extension + .getReconcilerOfType(WorkflowActivationConditionReconciler.class) + .getNumberOfReconciliationExecution()) + .isEqualTo(1); + }); + } + + private GetNonActiveSecondaryCustomResource testResource() { + var res = new GetNonActiveSecondaryCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/WorkflowActivationConditionReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/WorkflowActivationConditionReconciler.java new file mode 100644 index 0000000000..076919d5d1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/getnonactivesecondary/WorkflowActivationConditionReconciler.java @@ -0,0 +1,42 @@ +package io.javaoperatorsdk.operator.workflow.getnonactivesecondary; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.openshift.api.model.Route; +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.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow( + dependents = { + @Dependent(type = ConfigMapDependentResource.class), + @Dependent( + type = RouteDependentResource.class, + activationCondition = FalseActivationCondition.class) + }) +@ControllerConfiguration +public class WorkflowActivationConditionReconciler + implements Reconciler { + + private final AtomicInteger numberOfReconciliationExecution = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + GetNonActiveSecondaryCustomResource resource, + Context context) { + + // should not throw an exception even if the condition is false + var route = context.getSecondaryResource(Route.class); + + numberOfReconciliationExecution.incrementAndGet(); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfReconciliationExecution() { + return numberOfReconciliationExecution.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ConfigMapDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ConfigMapDependent.java new file mode 100644 index 0000000000..adc633b877 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ConfigMapDependent.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.workflow.manageddependentdeletecondition; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; + +public class ConfigMapDependent + extends CRUDNoGCKubernetesDependentResource< + ConfigMap, ManagedDependentDefaultDeleteConditionCustomResource> { + + @Override + protected ConfigMap desired( + ManagedDependentDefaultDeleteConditionCustomResource primary, + Context context) { + + return new ConfigMapBuilder() + .withNewMetadata() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .endMetadata() + .withData(Map.of("key", "val")) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ManagedDependentDefaultDeleteConditionCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ManagedDependentDefaultDeleteConditionCustomResource.java new file mode 100644 index 0000000000..c421e75b54 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ManagedDependentDefaultDeleteConditionCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.manageddependentdeletecondition; + +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("mdcc") +public class ManagedDependentDefaultDeleteConditionCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ManagedDependentDefaultDeleteConditionReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ManagedDependentDefaultDeleteConditionReconciler.java new file mode 100644 index 0000000000..d992e9a1b4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ManagedDependentDefaultDeleteConditionReconciler.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.workflow.manageddependentdeletecondition; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.dependent.workflow.KubernetesResourceDeletedCondition; + +@Workflow( + dependents = { + @Dependent(name = "ConfigMap", type = ConfigMapDependent.class), + @Dependent( + type = SecretDependent.class, + dependsOn = "ConfigMap", + deletePostcondition = KubernetesResourceDeletedCondition.class) + }) +@ControllerConfiguration +public class ManagedDependentDefaultDeleteConditionReconciler + implements Reconciler { + + private static final Logger log = + LoggerFactory.getLogger(ManagedDependentDefaultDeleteConditionReconciler.class); + + @Override + public UpdateControl reconcile( + ManagedDependentDefaultDeleteConditionCustomResource resource, + Context context) { + + log.debug("Reconciled: {}", resource); + + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ManagedDependentDeleteConditionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ManagedDependentDeleteConditionIT.java new file mode 100644 index 0000000000..482d2e5091 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/ManagedDependentDeleteConditionIT.java @@ -0,0 +1,74 @@ +package io.javaoperatorsdk.operator.workflow.manageddependentdeletecondition; + +import java.time.Duration; +import java.util.Set; + +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.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class ManagedDependentDeleteConditionIT { + + public static final String RESOURCE_NAME = "test1"; + public static final String CUSTOM_FINALIZER = "test/customfinalizer"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withConfigurationService(o -> o.withDefaultNonSSAResource(Set.of())) + .withReconciler(new ManagedDependentDefaultDeleteConditionReconciler()) + .build(); + + @Test + void resourceNotDeletedUntilDependentDeleted() { + var resource = new ManagedDependentDefaultDeleteConditionCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + resource = extension.create(resource); + + await() + .timeout(Duration.ofSeconds(300)) + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, RESOURCE_NAME); + var sec = extension.get(Secret.class, RESOURCE_NAME); + assertThat(cm).isNotNull(); + assertThat(sec).isNotNull(); + }); + + var secret = extension.get(Secret.class, RESOURCE_NAME); + secret.getMetadata().getFinalizers().add(CUSTOM_FINALIZER); + secret = extension.replace(secret); + + extension.delete(resource); + + // both resources are present until the finalizer removed + await() + .pollDelay(Duration.ofMillis(250)) + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, RESOURCE_NAME); + var sec = extension.get(Secret.class, RESOURCE_NAME); + assertThat(cm).isNotNull(); + assertThat(sec).isNotNull(); + }); + + secret.getMetadata().getFinalizers().clear(); + extension.replace(secret); + + await() + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, RESOURCE_NAME); + var sec = extension.get(Secret.class, RESOURCE_NAME); + assertThat(cm).isNull(); + assertThat(sec).isNull(); + }); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/SecretDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/SecretDependent.java new file mode 100644 index 0000000000..a7d52511ea --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/manageddependentdeletecondition/SecretDependent.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.workflow.manageddependentdeletecondition; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; + +public class SecretDependent + extends CRUDNoGCKubernetesDependentResource< + Secret, ManagedDependentDefaultDeleteConditionCustomResource> { + + @Override + protected Secret desired( + ManagedDependentDefaultDeleteConditionCustomResource primary, + Context context) { + + return new SecretBuilder() + .withNewMetadata() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .endMetadata() + .withData( + Map.of( + "key", + new String(Base64.getEncoder().encode("val".getBytes(StandardCharsets.UTF_16))))) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/ActivationCondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/ActivationCondition.java new file mode 100644 index 0000000000..063bb6b72c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/ActivationCondition.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.workflow.multipledependentwithactivation; + +import io.fabric8.openshift.api.model.Route; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class ActivationCondition + implements Condition { + + public static volatile boolean MET = false; + + @Override + public boolean isMet( + DependentResource dependentResource, + MultipleDependentActivationCustomResource primary, + Context context) { + return MET; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/ConfigMapDependentResource1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/ConfigMapDependentResource1.java new file mode 100644 index 0000000000..ed83b870ab --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/ConfigMapDependentResource1.java @@ -0,0 +1,33 @@ +package io.javaoperatorsdk.operator.workflow.multipledependentwithactivation; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent(informer = @Informer(name = "configMapInformer")) +public class ConfigMapDependentResource1 + extends CRUDNoGCKubernetesDependentResource< + ConfigMap, MultipleDependentActivationCustomResource> { + + public static final String DATA_KEY = "data"; + public static final String SUFFIX = "1"; + + @Override + protected ConfigMap desired( + MultipleDependentActivationCustomResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName() + SUFFIX) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of(DATA_KEY, primary.getSpec().getValue() + SUFFIX)); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/ConfigMapDependentResource2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/ConfigMapDependentResource2.java new file mode 100644 index 0000000000..73ccb55cdb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/ConfigMapDependentResource2.java @@ -0,0 +1,33 @@ +package io.javaoperatorsdk.operator.workflow.multipledependentwithactivation; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent(informer = @Informer(name = "configMapInformer")) +public class ConfigMapDependentResource2 + extends CRUDNoGCKubernetesDependentResource< + ConfigMap, MultipleDependentActivationCustomResource> { + + public static final String DATA_KEY = "data"; + public static final String SUFFIX = "2"; + + @Override + protected ConfigMap desired( + MultipleDependentActivationCustomResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName() + SUFFIX) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of(DATA_KEY, primary.getSpec().getValue() + SUFFIX)); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/MultipleDependentActivationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/MultipleDependentActivationCustomResource.java new file mode 100644 index 0000000000..fc68a98e56 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/MultipleDependentActivationCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.multipledependentwithactivation; + +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("mdar") +public class MultipleDependentActivationCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/MultipleDependentActivationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/MultipleDependentActivationReconciler.java new file mode 100644 index 0000000000..9953120e76 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/MultipleDependentActivationReconciler.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.workflow.multipledependentwithactivation; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow( + dependents = { + @Dependent( + type = ConfigMapDependentResource1.class, + activationCondition = ActivationCondition.class), + @Dependent( + type = ConfigMapDependentResource2.class, + activationCondition = ActivationCondition.class), + @Dependent(type = SecretDependentResource.class) + }) +@ControllerConfiguration +public class MultipleDependentActivationReconciler + implements Reconciler { + + private final AtomicInteger numberOfReconciliationExecution = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + MultipleDependentActivationCustomResource resource, + Context context) { + + numberOfReconciliationExecution.incrementAndGet(); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfReconciliationExecution() { + return numberOfReconciliationExecution.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/MultipleDependentActivationSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/MultipleDependentActivationSpec.java new file mode 100644 index 0000000000..76f39911fe --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/MultipleDependentActivationSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.workflow.multipledependentwithactivation; + +public class MultipleDependentActivationSpec { + + 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/workflow/multipledependentwithactivation/MultipleDependentWithActivationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/MultipleDependentWithActivationIT.java new file mode 100644 index 0000000000..2360660adc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/MultipleDependentWithActivationIT.java @@ -0,0 +1,82 @@ +package io.javaoperatorsdk.operator.workflow.multipledependentwithactivation; + +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.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class MultipleDependentWithActivationIT { + + public static final String INITIAL_VALUE = "initial_value"; + public static final String CHANGED_VALUE = "changed_value"; + public static final String TEST_RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new MultipleDependentActivationReconciler()) + .build(); + + @Test + void bothDependentsWithActivationAreHandled() { + var resource = extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var cm1 = + extension.get( + ConfigMap.class, TEST_RESOURCE_NAME + ConfigMapDependentResource1.SUFFIX); + var cm2 = + extension.get( + ConfigMap.class, TEST_RESOURCE_NAME + ConfigMapDependentResource2.SUFFIX); + var secret = extension.get(Secret.class, TEST_RESOURCE_NAME); + assertThat(secret).isNotNull(); + assertThat(cm1).isNull(); + assertThat(cm2).isNull(); + }); + + ActivationCondition.MET = true; + resource.getSpec().setValue(CHANGED_VALUE); + extension.replace(resource); + + await() + .untilAsserted( + () -> { + var cm1 = + extension.get( + ConfigMap.class, TEST_RESOURCE_NAME + ConfigMapDependentResource1.SUFFIX); + var cm2 = + extension.get( + ConfigMap.class, TEST_RESOURCE_NAME + ConfigMapDependentResource2.SUFFIX); + var secret = extension.get(Secret.class, TEST_RESOURCE_NAME); + + assertThat(secret).isNotNull(); + assertThat(cm1).isNotNull(); + assertThat(cm2).isNotNull(); + assertThat(cm1.getData()) + .containsEntry( + ConfigMapDependentResource1.DATA_KEY, + CHANGED_VALUE + ConfigMapDependentResource1.SUFFIX); + assertThat(cm2.getData()) + .containsEntry( + ConfigMapDependentResource2.DATA_KEY, + CHANGED_VALUE + ConfigMapDependentResource2.SUFFIX); + }); + } + + MultipleDependentActivationCustomResource testResource() { + var res = new MultipleDependentActivationCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + res.setSpec(new MultipleDependentActivationSpec()); + res.getSpec().setValue(INITIAL_VALUE); + + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/SecretDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/SecretDependentResource.java new file mode 100644 index 0000000000..330f0e3c0f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/multipledependentwithactivation/SecretDependentResource.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.workflow.multipledependentwithactivation; + +import java.util.Base64; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class SecretDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected Secret desired( + MultipleDependentActivationCustomResource primary, + Context context) { + // basically does not matter since this should not be called + Secret secret = new Secret(); + secret.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + secret.setData( + Map.of( + "data", Base64.getEncoder().encodeToString(primary.getSpec().getValue().getBytes()))); + return secret; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/ConfigMapDependentResource1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/ConfigMapDependentResource1.java new file mode 100644 index 0000000000..eec904d2c7 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/ConfigMapDependentResource1.java @@ -0,0 +1,43 @@ +package io.javaoperatorsdk.operator.workflow.orderedmanageddependent; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent(informer = @Informer(labelSelector = "dependent = cm1")) +public class ConfigMapDependentResource1 + extends CRUDKubernetesDependentResource { + + @Override + public ReconcileResult reconcile( + OrderedManagedDependentCustomResource primary, + Context context) { + OrderedManagedDependentTestReconciler.dependentExecution.add(this.getClass()); + return super.reconcile(primary, context); + } + + @Override + protected ConfigMap desired( + OrderedManagedDependentCustomResource primary, + Context context) { + + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new ObjectMeta()); + Map labels = new HashMap<>(); + labels.put("dependent", "cm1"); + configMap.getMetadata().setLabels(labels); + configMap.getMetadata().setName(primary.getMetadata().getName() + "1"); + configMap.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + HashMap data = new HashMap<>(); + data.put("key1", "val1"); + configMap.setData(data); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/ConfigMapDependentResource2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/ConfigMapDependentResource2.java new file mode 100644 index 0000000000..8e4a1467ec --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/ConfigMapDependentResource2.java @@ -0,0 +1,43 @@ +package io.javaoperatorsdk.operator.workflow.orderedmanageddependent; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent(informer = @Informer(labelSelector = "dependent = cm2")) +public class ConfigMapDependentResource2 + extends CRUDKubernetesDependentResource { + + @Override + public ReconcileResult reconcile( + OrderedManagedDependentCustomResource primary, + Context context) { + OrderedManagedDependentTestReconciler.dependentExecution.add(this.getClass()); + return super.reconcile(primary, context); + } + + @Override + protected ConfigMap desired( + OrderedManagedDependentCustomResource primary, + Context context) { + + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new ObjectMeta()); + Map labels = new HashMap<>(); + labels.put("dependent", "cm2"); + configMap.getMetadata().setLabels(labels); + configMap.getMetadata().setName(primary.getMetadata().getName() + "2"); + configMap.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + HashMap data = new HashMap<>(); + data.put("key2", "val2"); + configMap.setData(data); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/OrderedManagedDependentCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/OrderedManagedDependentCustomResource.java new file mode 100644 index 0000000000..ec3622dd86 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/OrderedManagedDependentCustomResource.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.workflow.orderedmanageddependent; + +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("OrderedManagedDependentCustomResource") +@ShortNames("omd") +public class OrderedManagedDependentCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/OrderedManagedDependentIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/OrderedManagedDependentIT.java new file mode 100644 index 0000000000..ff25911a8c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/OrderedManagedDependentIT.java @@ -0,0 +1,47 @@ +package io.javaoperatorsdk.operator.workflow.orderedmanageddependent; + +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.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class OrderedManagedDependentIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new OrderedManagedDependentTestReconciler()) + .build(); + + @Test + void managedDependentsAreReconciledInOrder() { + operator.create(createTestResource()); + + await() + .pollDelay(Duration.ofSeconds(1)) + .atMost(Duration.ofSeconds(5)) + .until( + () -> + ((OrderedManagedDependentTestReconciler) operator.getFirstReconciler()) + .getNumberOfExecutions() + == 1); + + assertThat(OrderedManagedDependentTestReconciler.dependentExecution.get(0)) + .isEqualTo(ConfigMapDependentResource1.class); + assertThat(OrderedManagedDependentTestReconciler.dependentExecution.get(1)) + .isEqualTo(ConfigMapDependentResource2.class); + } + + private OrderedManagedDependentCustomResource createTestResource() { + OrderedManagedDependentCustomResource cr = new OrderedManagedDependentCustomResource(); + cr.setMetadata(new ObjectMeta()); + cr.getMetadata().setName("test"); + return cr; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/OrderedManagedDependentTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/OrderedManagedDependentTestReconciler.java new file mode 100644 index 0000000000..77c3d830c1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/OrderedManagedDependentTestReconciler.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.workflow.orderedmanageddependent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@Workflow( + dependents = { + @Dependent(type = ConfigMapDependentResource1.class, name = "cm1"), + @Dependent(type = ConfigMapDependentResource2.class, dependsOn = "cm1") + }) +@ControllerConfiguration(informer = @Informer(namespaces = Constants.WATCH_CURRENT_NAMESPACE)) +public class OrderedManagedDependentTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + public static final List> dependentExecution = + Collections.synchronizedList(new ArrayList<>()); + + @Override + public UpdateControl reconcile( + OrderedManagedDependentCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/ConfigMapDependentResource.java new file mode 100644 index 0000000000..cb2357bf8b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/ConfigMapDependentResource.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcleanup; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; + +public class ConfigMapDependentResource + extends CRUDNoGCKubernetesDependentResource< + ConfigMap, WorkflowActivationCleanupCustomResource> { + + public static final String DATA_KEY = "data"; + + @Override + protected ConfigMap desired( + WorkflowActivationCleanupCustomResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of(DATA_KEY, primary.getSpec().getValue())); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/TestActivcationCondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/TestActivcationCondition.java new file mode 100644 index 0000000000..fd92065f15 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/TestActivcationCondition.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcleanup; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class TestActivcationCondition + implements Condition { + + @Override + public boolean isMet( + DependentResource dependentResource, + WorkflowActivationCleanupCustomResource primary, + Context context) { + return true; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupCustomResource.java new file mode 100644 index 0000000000..195ac565fd --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcleanup; + +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("wacc") +public class WorkflowActivationCleanupCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupIT.java new file mode 100644 index 0000000000..80bbf22d40 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupIT.java @@ -0,0 +1,82 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcleanup; + +import org.junit.jupiter.api.*; + +import io.fabric8.kubernetes.api.model.Namespace; +import io.fabric8.kubernetes.api.model.NamespaceBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.fabric8.kubernetes.client.utils.KubernetesResourceUtil; +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class WorkflowActivationCleanupIT { + + private final KubernetesClient client = new KubernetesClientBuilder().build(); + private Operator operator; + + private String testNamespace; + + @BeforeEach + void beforeEach(TestInfo testInfo) { + LocallyRunOperatorExtension.applyCrd(WorkflowActivationCleanupCustomResource.class, client); + + testInfo + .getTestMethod() + .ifPresent(method -> testNamespace = KubernetesResourceUtil.sanitizeName(method.getName())); + client.namespaces().resource(testNamespace(testNamespace)).create(); + operator = new Operator(o -> o.withCloseClientOnStop(false)); + operator.register( + new WorkflowActivationCleanupReconciler(), o -> o.settingNamespaces(testNamespace)); + } + + @AfterEach + void stopOperator() { + client.namespaces().withName(testNamespace).delete(); + await() + .untilAsserted( + () -> { + var ns = client.namespaces().withName(testNamespace).get(); + assertThat(ns).isNull(); + }); + operator.stop(); + } + + @Test + void testCleanupOnMarkedResourceOnOperatorStartup() { + var resource = client.resource(testResourceWithFinalizer()).create(); + client.resource(resource).delete(); + operator.start(); + + await() + .untilAsserted( + () -> { + var res = client.resource(resource).get(); + assertThat(res).isNull(); + }); + } + + private WorkflowActivationCleanupCustomResource testResourceWithFinalizer() { + var resource = new WorkflowActivationCleanupCustomResource(); + resource.setMetadata( + new ObjectMetaBuilder() + .withName("test1") + .withFinalizers( + "workflowactivationcleanupcustomresources.sample.javaoperatorsdk/finalizer") + .withNamespace(testNamespace) + .build()); + resource.setSpec(new WorkflowActivationCleanupSpec()); + resource.getSpec().setValue("val1"); + return resource; + } + + private Namespace testNamespace(String name) { + return new NamespaceBuilder() + .withMetadata(new ObjectMetaBuilder().withName(name).build()) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupReconciler.java new file mode 100644 index 0000000000..ea158c9e08 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupReconciler.java @@ -0,0 +1,31 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcleanup; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow( + dependents = { + @Dependent( + type = ConfigMapDependentResource.class, + activationCondition = TestActivcationCondition.class), + }) +@ControllerConfiguration +public class WorkflowActivationCleanupReconciler + implements Reconciler, + Cleaner { + + @Override + public UpdateControl reconcile( + WorkflowActivationCleanupCustomResource resource, + Context context) { + + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup( + WorkflowActivationCleanupCustomResource resource, + Context context) { + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupSpec.java new file mode 100644 index 0000000000..1719bb535f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcleanup/WorkflowActivationCleanupSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcleanup; + +public class WorkflowActivationCleanupSpec { + + 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/workflow/workflowactivationcondition/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/ConfigMapDependentResource.java new file mode 100644 index 0000000000..5f2e92ed55 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/ConfigMapDependentResource.java @@ -0,0 +1,28 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcondition; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class ConfigMapDependentResource + extends CRUDKubernetesDependentResource { + + public static final String DATA_KEY = "data"; + + @Override + protected ConfigMap desired( + WorkflowActivationConditionCustomResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of(DATA_KEY, primary.getSpec().getValue())); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/IsOpenShiftCondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/IsOpenShiftCondition.java new file mode 100644 index 0000000000..5665590527 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/IsOpenShiftCondition.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcondition; + +import io.fabric8.openshift.api.model.Route; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class IsOpenShiftCondition + implements Condition { + @Override + public boolean isMet( + DependentResource dependentResource, + WorkflowActivationConditionCustomResource primary, + Context context) { + // we are testing if the reconciliation still works on Kubernetes, so this always false; + return false; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/RouteDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/RouteDependentResource.java new file mode 100644 index 0000000000..7d2d091c94 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/RouteDependentResource.java @@ -0,0 +1,25 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcondition; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.openshift.api.model.Route; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class RouteDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected Route desired( + WorkflowActivationConditionCustomResource primary, + Context context) { + // basically does not matter since this should not be called + Route route = new Route(); + route.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + + return route; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionCustomResource.java new file mode 100644 index 0000000000..9c6347f032 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcondition; + +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("wac") +public class WorkflowActivationConditionCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionIT.java new file mode 100644 index 0000000000..a5b5b23fe3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionIT.java @@ -0,0 +1,46 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcondition; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.workflow.workflowactivationcondition.ConfigMapDependentResource.DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class WorkflowActivationConditionIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String TEST_DATA = "test data"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(WorkflowActivationConditionReconciler.class) + .build(); + + // Without activation condition this would fail / there would be errors. + @Test + void reconciledOnVanillaKubernetesDespiteRouteInWorkflow() { + extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(cm).isNotNull(); + assertThat(cm.getData()).containsEntry(DATA_KEY, TEST_DATA); + }); + } + + private WorkflowActivationConditionCustomResource testResource() { + var res = new WorkflowActivationConditionCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + res.setSpec(new WorkflowActivationConditionSpec()); + res.getSpec().setValue(TEST_DATA); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionReconciler.java new file mode 100644 index 0000000000..b8bcb210c5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionReconciler.java @@ -0,0 +1,24 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcondition; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow( + dependents = { + @Dependent(type = ConfigMapDependentResource.class), + @Dependent( + type = RouteDependentResource.class, + activationCondition = IsOpenShiftCondition.class) + }) +@ControllerConfiguration +public class WorkflowActivationConditionReconciler + implements Reconciler { + + @Override + public UpdateControl reconcile( + WorkflowActivationConditionCustomResource resource, + Context context) { + + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionSpec.java new file mode 100644 index 0000000000..aa3b6b5f9c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowactivationcondition/WorkflowActivationConditionSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.workflow.workflowactivationcondition; + +public class WorkflowActivationConditionSpec { + + 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/workflow/workflowallfeature/ConfigMapDeletePostCondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/ConfigMapDeletePostCondition.java new file mode 100644 index 0000000000..c9d9393ce1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/ConfigMapDeletePostCondition.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.workflow.workflowallfeature; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class ConfigMapDeletePostCondition + implements Condition { + + private static final Logger log = LoggerFactory.getLogger(ConfigMapDeletePostCondition.class); + + @Override + public boolean isMet( + DependentResource dependentResource, + WorkflowAllFeatureCustomResource primary, + Context context) { + + var configMapDeleted = dependentResource.getSecondaryResource(primary, context).isEmpty(); + log.debug("Config Map Deleted: {}", configMapDeleted); + return configMapDeleted; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/ConfigMapDependentResource.java new file mode 100644 index 0000000000..fac6ecae88 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/ConfigMapDependentResource.java @@ -0,0 +1,57 @@ +package io.javaoperatorsdk.operator.workflow.workflowallfeature; + +import java.util.Map; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Updater; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public class ConfigMapDependentResource + extends KubernetesDependentResource + implements Creator, + Updater, + Deleter { + + public static final String READY_TO_DELETE_ANNOTATION = "ready-to-delete"; + + private static final Logger log = LoggerFactory.getLogger(ConfigMapDependentResource.class); + + @Override + protected ConfigMap desired( + WorkflowAllFeatureCustomResource primary, Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of("key", "data")); + return configMap; + } + + @Override + public void delete( + WorkflowAllFeatureCustomResource primary, Context context) { + Optional optionalConfigMap = context.getSecondaryResource(ConfigMap.class); + if (optionalConfigMap.isEmpty()) { + log.debug("Config Map not found for primary: {}", ResourceID.fromResource(primary)); + return; + } + optionalConfigMap.ifPresent( + (configMap -> { + if (configMap.getMetadata().getAnnotations() != null + && configMap.getMetadata().getAnnotations().get(READY_TO_DELETE_ANNOTATION) != null) { + context.getClient().resource(configMap).delete(); + } + })); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/ConfigMapReconcileCondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/ConfigMapReconcileCondition.java new file mode 100644 index 0000000000..0854908dd8 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/ConfigMapReconcileCondition.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.operator.workflow.workflowallfeature; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.DetailedCondition; + +public class ConfigMapReconcileCondition + implements DetailedCondition { + + public static final String CREATE_SET = "create set"; + public static final String CREATE_NOT_SET = "create not set"; + public static final String NOT_RECONCILED_YET = "Not reconciled yet"; + + @Override + public Result detailedIsMet( + DependentResource dependentResource, + WorkflowAllFeatureCustomResource primary, + Context context) { + final var createConfigMap = primary.getSpec().isCreateConfigMap(); + return Result.withResult(createConfigMap, createConfigMap ? CREATE_SET : CREATE_NOT_SET); + } +} 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 new file mode 100644 index 0000000000..92956e05f6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentDependentResource.java @@ -0,0 +1,24 @@ +package io.javaoperatorsdk.operator.workflow.workflowallfeature; + +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.javaoperatorsdk.operator.ReconcilerUtils; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; + +public class DeploymentDependentResource + extends CRUDNoGCKubernetesDependentResource { + + @Override + protected Deployment desired( + WorkflowAllFeatureCustomResource primary, Context context) { + Deployment deployment = + ReconcilerUtils.loadYaml( + Deployment.class, + WorkflowAllFeatureIT.class, + "/io/javaoperatorsdk/operator/nginx-deployment.yaml"); + deployment.getMetadata().setName(primary.getMetadata().getName()); + deployment.getSpec().setReplicas(2); + deployment.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + return deployment; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentReadyCondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentReadyCondition.java new file mode 100644 index 0000000000..40ad17e680 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/DeploymentReadyCondition.java @@ -0,0 +1,25 @@ +package io.javaoperatorsdk.operator.workflow.workflowallfeature; + +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class DeploymentReadyCondition + implements Condition { + @Override + public boolean isMet( + DependentResource dependentResource, + WorkflowAllFeatureCustomResource primary, + Context context) { + return dependentResource + .getSecondaryResource(primary, context) + .map( + deployment -> { + var readyReplicas = deployment.getStatus().getReadyReplicas(); + return readyReplicas != null + && deployment.getSpec().getReplicas().equals(readyReplicas); + }) + .orElse(false); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureCustomResource.java new file mode 100644 index 0000000000..4b3a75b10b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureCustomResource.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.workflow.workflowallfeature; + +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("waf") +public class WorkflowAllFeatureCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureIT.java new file mode 100644 index 0000000000..93551dcf43 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureIT.java @@ -0,0 +1,144 @@ +package io.javaoperatorsdk.operator.workflow.workflowallfeature; + +import java.time.Duration; +import java.util.HashMap; + +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.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.workflow.workflowallfeature.ConfigMapDependentResource.READY_TO_DELETE_ANNOTATION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class WorkflowAllFeatureIT { + + public static final String RESOURCE_NAME = "test"; + private static final Duration ONE_MINUTE = Duration.ofMinutes(1); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(WorkflowAllFeatureReconciler.class) + .build(); + + @Test + void configMapNotReconciledUntilDeploymentReady() { + operator.create(customResource(true)); + await() + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(WorkflowAllFeatureReconciler.class) + .getNumberOfReconciliationExecution()) + .isPositive(); + assertThat(operator.get(Deployment.class, RESOURCE_NAME)).isNotNull(); + assertThat(operator.get(ConfigMap.class, RESOURCE_NAME)).isNull(); + assertThat(getPrimaryStatus().getMsgFromCondition()) + .isEqualTo(ConfigMapReconcileCondition.NOT_RECONCILED_YET); + }); + + await() + .atMost(ONE_MINUTE) + .untilAsserted( + () -> { + assertThat( + operator + .getReconcilerOfType(WorkflowAllFeatureReconciler.class) + .getNumberOfReconciliationExecution()) + .isGreaterThan(1); + assertThat(operator.get(ConfigMap.class, RESOURCE_NAME)).isNotNull(); + final var primaryStatus = getPrimaryStatus(); + assertThat(primaryStatus.getReady()).isTrue(); + assertThat(primaryStatus.getMsgFromCondition()) + .isEqualTo(ConfigMapReconcileCondition.CREATE_SET); + }); + + markConfigMapForDelete(); + } + + private WorkflowAllFeatureStatus getPrimaryStatus() { + return operator.get(WorkflowAllFeatureCustomResource.class, RESOURCE_NAME).getStatus(); + } + + @Test + void configMapNotReconciledIfReconcileConditionNotMet() { + var resource = operator.create(customResource(false)); + + await() + .atMost(ONE_MINUTE) + .untilAsserted( + () -> { + assertThat(operator.get(ConfigMap.class, RESOURCE_NAME)).isNull(); + assertThat(getPrimaryStatus().getReady()).isTrue(); + }); + + resource.getSpec().setCreateConfigMap(true); + operator.replace(resource); + + await() + .untilAsserted( + () -> { + assertThat(operator.get(ConfigMap.class, RESOURCE_NAME)).isNotNull(); + assertThat(getPrimaryStatus().getReady()).isTrue(); + }); + } + + @Test + void configMapNotDeletedUntilNotMarked() { + var resource = operator.create(customResource(true)); + + await() + .atMost(ONE_MINUTE) + .untilAsserted( + () -> { + assertThat(getPrimaryStatus()).isNotNull(); + assertThat(getPrimaryStatus().getReady()).isTrue(); + assertThat(operator.get(ConfigMap.class, RESOURCE_NAME)).isNotNull(); + }); + + operator.delete(resource); + + await() + .pollDelay(Duration.ofMillis(300)) + .untilAsserted( + () -> { + assertThat(operator.get(ConfigMap.class, RESOURCE_NAME)).isNotNull(); + assertThat(operator.get(WorkflowAllFeatureCustomResource.class, RESOURCE_NAME)) + .isNotNull(); + }); + + markConfigMapForDelete(); + + await() + .atMost(ONE_MINUTE) + .untilAsserted( + () -> { + assertThat(operator.get(ConfigMap.class, RESOURCE_NAME)).isNull(); + assertThat(operator.get(WorkflowAllFeatureCustomResource.class, RESOURCE_NAME)) + .isNull(); + }); + } + + private void markConfigMapForDelete() { + var cm = operator.get(ConfigMap.class, RESOURCE_NAME); + if (cm.getMetadata().getAnnotations() == null) { + cm.getMetadata().setAnnotations(new HashMap<>()); + } + cm.getMetadata().getAnnotations().put(READY_TO_DELETE_ANNOTATION, "true"); + operator.replace(cm); + } + + private WorkflowAllFeatureCustomResource customResource(boolean createConfigMap) { + var res = new WorkflowAllFeatureCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + res.setSpec(new WorkflowAllFeatureSpec()); + res.getSpec().setCreateConfigMap(createConfigMap); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureReconciler.java new file mode 100644 index 0000000000..9e9a365e04 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureReconciler.java @@ -0,0 +1,80 @@ +package io.javaoperatorsdk.operator.workflow.workflowallfeature; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +import static io.javaoperatorsdk.operator.workflow.workflowallfeature.WorkflowAllFeatureReconciler.DEPLOYMENT_NAME; + +@Workflow( + dependents = { + @Dependent( + name = DEPLOYMENT_NAME, + type = DeploymentDependentResource.class, + readyPostcondition = DeploymentReadyCondition.class), + @Dependent( + type = ConfigMapDependentResource.class, + reconcilePrecondition = ConfigMapReconcileCondition.class, + deletePostcondition = ConfigMapDeletePostCondition.class, + dependsOn = DEPLOYMENT_NAME) + }) +@ControllerConfiguration +public class WorkflowAllFeatureReconciler + implements Reconciler, + Cleaner { + + public static final String DEPLOYMENT_NAME = "deployment"; + + private final AtomicInteger numberOfReconciliationExecution = new AtomicInteger(0); + private final AtomicInteger numberOfCleanupExecution = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + WorkflowAllFeatureCustomResource resource, + Context context) { + numberOfReconciliationExecution.addAndGet(1); + if (resource.getStatus() == null) { + resource.setStatus(new WorkflowAllFeatureStatus()); + } + final var reconcileResult = + context.managedWorkflowAndDependentResourceContext().getWorkflowReconcileResult(); + final var msgFromCondition = + reconcileResult + .orElseThrow() + .getDependentConditionResult( + DependentResource.defaultNameFor(ConfigMapDependentResource.class), + Condition.Type.RECONCILE, + String.class) + .orElse(ConfigMapReconcileCondition.NOT_RECONCILED_YET); + resource + .getStatus() + .withReady(reconcileResult.orElseThrow().allDependentResourcesReady()) + .withMsgFromCondition(msgFromCondition); + return UpdateControl.patchStatus(resource); + } + + public int getNumberOfReconciliationExecution() { + return numberOfReconciliationExecution.get(); + } + + public int getNumberOfCleanupExecution() { + return numberOfCleanupExecution.get(); + } + + @Override + public DeleteControl cleanup( + WorkflowAllFeatureCustomResource resource, + Context context) { + numberOfCleanupExecution.addAndGet(1); + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureSpec.java new file mode 100644 index 0000000000..fb7ced3753 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.workflow.workflowallfeature; + +public class WorkflowAllFeatureSpec { + + private boolean createConfigMap = false; + + public boolean isCreateConfigMap() { + return createConfigMap; + } + + public WorkflowAllFeatureSpec setCreateConfigMap(boolean createConfigMap) { + this.createConfigMap = createConfigMap; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureStatus.java new file mode 100644 index 0000000000..a1a83e4abc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowallfeature/WorkflowAllFeatureStatus.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.workflow.workflowallfeature; + +public class WorkflowAllFeatureStatus { + + private Boolean ready; + private String msgFromCondition; + + public Boolean getReady() { + return ready; + } + + public String getMsgFromCondition() { + return msgFromCondition; + } + + public WorkflowAllFeatureStatus withReady(Boolean ready) { + this.ready = ready; + return this; + } + + @SuppressWarnings("UnusedReturnValue") + public WorkflowAllFeatureStatus withMsgFromCondition(String msgFromCondition) { + this.msgFromCondition = msgFromCondition; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/ConfigMapDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/ConfigMapDependent.java new file mode 100644 index 0000000000..17190c5a92 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/ConfigMapDependent.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.workflow.workflowexplicitcleanup; + +import java.util.Map; + +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.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; + +public class ConfigMapDependent + extends CRUDNoGCKubernetesDependentResource { + + @Override + protected ConfigMap desired( + WorkflowExplicitCleanupCustomResource primary, + Context context) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of("key", "val")) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupCustomResource.java new file mode 100644 index 0000000000..8622421db9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.workflowexplicitcleanup; + +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("wec") +public class WorkflowExplicitCleanupCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupIT.java new file mode 100644 index 0000000000..b6d4969fd3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupIT.java @@ -0,0 +1,49 @@ +package io.javaoperatorsdk.operator.workflow.workflowexplicitcleanup; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class WorkflowExplicitCleanupIT { + + public static final String RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(WorkflowExplicitCleanupReconciler.class) + .build(); + + @Test + void workflowInvokedExplicitly() { + var res = extension.create(testResource()); + + await() + .untilAsserted( + () -> { + assertThat(extension.get(ConfigMap.class, RESOURCE_NAME)).isNotNull(); + }); + + extension.delete(res); + + // The ConfigMap is not garbage collected, this tests that even if the cleaner is not + // implemented the workflow cleanup still called even if there is explicit invocation + await() + .untilAsserted( + () -> { + assertThat(extension.get(ConfigMap.class, RESOURCE_NAME)).isNull(); + }); + } + + WorkflowExplicitCleanupCustomResource testResource() { + var res = new WorkflowExplicitCleanupCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupReconciler.java new file mode 100644 index 0000000000..1dac660839 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupReconciler.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.workflow.workflowexplicitcleanup; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow(explicitInvocation = true, dependents = @Dependent(type = ConfigMapDependent.class)) +@ControllerConfiguration +public class WorkflowExplicitCleanupReconciler + implements Reconciler, + Cleaner { + + @Override + public UpdateControl reconcile( + WorkflowExplicitCleanupCustomResource resource, + Context context) { + + context.managedWorkflowAndDependentResourceContext().reconcileManagedWorkflow(); + + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup( + WorkflowExplicitCleanupCustomResource resource, + Context context) { + + context.managedWorkflowAndDependentResourceContext().cleanupManageWorkflow(); + // this can be checked + // context.managedWorkflowAndDependentResourceContext().getWorkflowCleanupResult() + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupSpec.java new file mode 100644 index 0000000000..93236cbf2c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitcleanup/WorkflowExplicitCleanupSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.workflow.workflowexplicitcleanup; + +public class WorkflowExplicitCleanupSpec { + + private String value; + + public String getValue() { + return value; + } + + public WorkflowExplicitCleanupSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/ConfigMapDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/ConfigMapDependent.java new file mode 100644 index 0000000000..a2638b48b5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/ConfigMapDependent.java @@ -0,0 +1,28 @@ +package io.javaoperatorsdk.operator.workflow.workflowexplicitinvocation; + +import java.util.Map; + +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.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; + +public class ConfigMapDependent + extends CRUDNoGCKubernetesDependentResource< + ConfigMap, WorkflowExplicitInvocationCustomResource> { + + @Override + protected ConfigMap desired( + WorkflowExplicitInvocationCustomResource primary, + Context context) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of("key", primary.getSpec().getValue())) + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationCustomResource.java new file mode 100644 index 0000000000..e625fa21d5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.workflowexplicitinvocation; + +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("wei") +public class WorkflowExplicitInvocationCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationIT.java new file mode 100644 index 0000000000..eaa799300f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationIT.java @@ -0,0 +1,69 @@ +package io.javaoperatorsdk.operator.workflow.workflowexplicitinvocation; + +import java.time.Duration; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class WorkflowExplicitInvocationIT { + + public static final String RESOURCE_NAME = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(WorkflowExplicitInvocationReconciler.class) + .build(); + + @Test + void workflowInvokedExplicitly() { + var res = extension.create(testResource()); + var reconciler = extension.getReconcilerOfType(WorkflowExplicitInvocationReconciler.class); + + await() + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions()).isEqualTo(1); + assertThat(extension.get(ConfigMap.class, RESOURCE_NAME)).isNull(); + }); + + reconciler.setInvokeWorkflow(true); + + // trigger reconciliation + res.getSpec().setValue("changed value"); + res = extension.replace(res); + + await() + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions()).isEqualTo(2); + assertThat(extension.get(ConfigMap.class, RESOURCE_NAME)).isNotNull(); + }); + + extension.delete(res); + + // The ConfigMap is not garbage collected, this tests that even if the cleaner is not + // implemented the workflow cleanup still called even if there is explicit invocation + await() + .timeout(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + assertThat(extension.get(ConfigMap.class, RESOURCE_NAME)).isNull(); + }); + } + + WorkflowExplicitInvocationCustomResource testResource() { + var res = new WorkflowExplicitInvocationCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + res.setSpec(new WorkflowExplicitInvocationSpec()); + res.getSpec().setValue("initial value"); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationReconciler.java new file mode 100644 index 0000000000..99249326f5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationReconciler.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.workflow.workflowexplicitinvocation; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow(explicitInvocation = true, dependents = @Dependent(type = ConfigMapDependent.class)) +@ControllerConfiguration +public class WorkflowExplicitInvocationReconciler + implements Reconciler { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + private volatile boolean invokeWorkflow = false; + + @Override + public UpdateControl reconcile( + WorkflowExplicitInvocationCustomResource resource, + Context context) { + + numberOfExecutions.addAndGet(1); + if (invokeWorkflow) { + context.managedWorkflowAndDependentResourceContext().reconcileManagedWorkflow(); + } + + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + public void setInvokeWorkflow(boolean invokeWorkflow) { + this.invokeWorkflow = invokeWorkflow; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationSpec.java new file mode 100644 index 0000000000..a50af9a9db --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowexplicitinvocation/WorkflowExplicitInvocationSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.workflow.workflowexplicitinvocation; + +public class WorkflowExplicitInvocationSpec { + + private String value; + + public String getValue() { + return value; + } + + public WorkflowExplicitInvocationSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/ActivationCondition.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/ActivationCondition.java new file mode 100644 index 0000000000..b69573c253 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/ActivationCondition.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.workflow.workflowmultipleactivation; + +import io.fabric8.openshift.api.model.Route; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class ActivationCondition + implements Condition { + + public static volatile boolean MET = true; + + @Override + public boolean isMet( + DependentResource dependentResource, + WorkflowMultipleActivationCustomResource primary, + Context context) { + return MET; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/ConfigMapDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/ConfigMapDependentResource.java new file mode 100644 index 0000000000..3366a61a1f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/ConfigMapDependentResource.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.workflow.workflowmultipleactivation; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; + +public class ConfigMapDependentResource + extends CRUDNoGCKubernetesDependentResource< + ConfigMap, WorkflowMultipleActivationCustomResource> { + + public static final String DATA_KEY = "data"; + + @Override + protected ConfigMap desired( + WorkflowMultipleActivationCustomResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of(DATA_KEY, primary.getSpec().getValue())); + return configMap; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/SecretDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/SecretDependentResource.java new file mode 100644 index 0000000000..cd1fbfcedc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/SecretDependentResource.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.workflow.workflowmultipleactivation; + +import java.util.Base64; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class SecretDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected Secret desired( + WorkflowMultipleActivationCustomResource primary, + Context context) { + // basically does not matter since this should not be called + Secret secret = new Secret(); + secret.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + secret.setData( + Map.of( + "data", Base64.getEncoder().encodeToString(primary.getSpec().getValue().getBytes()))); + return secret; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationCustomResource.java new file mode 100644 index 0000000000..4951108ff5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.workflowmultipleactivation; + +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("mwac") +public class WorkflowMultipleActivationCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationIT.java new file mode 100644 index 0000000000..0d5f3b60f1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationIT.java @@ -0,0 +1,157 @@ +package io.javaoperatorsdk.operator.workflow.workflowmultipleactivation; + +import java.time.Duration; + +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.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.workflow.workflowactivationcondition.ConfigMapDependentResource.DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class WorkflowMultipleActivationIT { + + public static final String INITIAL_DATA = "initial data"; + public static final String TEST_RESOURCE1 = "test1"; + public static final String TEST_RESOURCE2 = "test2"; + public static final String CHANGED_VALUE = "changed value"; + public static final int POLL_DELAY = 300; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(WorkflowMultipleActivationReconciler.class) + .build(); + + @Test + void deactivatingAndReactivatingDependent() { + ActivationCondition.MET = true; + var cr1 = extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE1); + var secret = extension.get(Secret.class, TEST_RESOURCE1); + assertThat(cm).isNotNull(); + assertThat(secret).isNotNull(); + assertThat(cm.getData()).containsEntry(DATA_KEY, INITIAL_DATA); + }); + + extension.delete(cr1); + + await() + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE1); + assertThat(cm).isNull(); + }); + + ActivationCondition.MET = false; + cr1 = extension.create(testResource()); + + await() + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE1); + var secret = extension.get(Secret.class, TEST_RESOURCE1); + assertThat(cm).isNull(); + assertThat(secret).isNotNull(); + }); + + ActivationCondition.MET = true; + cr1.getSpec().setValue(CHANGED_VALUE); + extension.replace(cr1); + + await() + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE1); + assertThat(cm).isNotNull(); + assertThat(cm.getData()).containsEntry(DATA_KEY, CHANGED_VALUE); + }); + + ActivationCondition.MET = false; + cr1.getSpec().setValue(INITIAL_DATA); + extension.replace(cr1); + + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE1); + assertThat(cm).isNotNull(); + // data not changed + assertThat(cm.getData()).containsEntry(DATA_KEY, CHANGED_VALUE); + }); + + var numOfReconciliation = + extension + .getReconcilerOfType(WorkflowMultipleActivationReconciler.class) + .getNumberOfReconciliationExecution(); + var actualCM = extension.get(ConfigMap.class, TEST_RESOURCE1); + actualCM.getData().put("data2", "additionaldata"); + extension.replace(actualCM); + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted( + () -> { + // change in config map does not induce reconciliation if inactive (thus informer is + // not + // present) + assertThat( + extension + .getReconcilerOfType(WorkflowMultipleActivationReconciler.class) + .getNumberOfReconciliationExecution()) + .isEqualTo(numOfReconciliation); + }); + + extension.delete(cr1); + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE1); + assertThat(cm).isNotNull(); + }); + } + + WorkflowMultipleActivationCustomResource testResource(String name) { + var res = new WorkflowMultipleActivationCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(name).build()); + res.setSpec(new WorkflowMultipleActivationSpec()); + res.getSpec().setValue(INITIAL_DATA); + return res; + } + + WorkflowMultipleActivationCustomResource testResource() { + return testResource(TEST_RESOURCE1); + } + + WorkflowMultipleActivationCustomResource testResource2() { + return testResource(TEST_RESOURCE2); + } + + @Test + void simpleConcurrencyTest() { + ActivationCondition.MET = true; + extension.create(testResource()); + extension.create(testResource2()); + + await() + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, TEST_RESOURCE1); + var cm2 = extension.get(ConfigMap.class, TEST_RESOURCE2); + assertThat(cm).isNotNull(); + assertThat(cm2).isNotNull(); + assertThat(cm.getData()).containsEntry(DATA_KEY, INITIAL_DATA); + assertThat(cm2.getData()).containsEntry(DATA_KEY, INITIAL_DATA); + }); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationReconciler.java new file mode 100644 index 0000000000..460638024f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationReconciler.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.workflow.workflowmultipleactivation; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow( + dependents = { + @Dependent( + type = ConfigMapDependentResource.class, + activationCondition = ActivationCondition.class), + @Dependent(type = SecretDependentResource.class) + }) +@ControllerConfiguration +public class WorkflowMultipleActivationReconciler + implements Reconciler { + + private final AtomicInteger numberOfReconciliationExecution = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + WorkflowMultipleActivationCustomResource resource, + Context context) { + + numberOfReconciliationExecution.incrementAndGet(); + + return UpdateControl.noUpdate(); + } + + public int getNumberOfReconciliationExecution() { + return numberOfReconciliationExecution.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationSpec.java new file mode 100644 index 0000000000..c9c9cf3a15 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.workflow.workflowmultipleactivation; + +public class WorkflowMultipleActivationSpec { + + 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/workflow/workflowsilentexceptionhandling/ConfigMapDependent.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling/ConfigMapDependent.java new file mode 100644 index 0000000000..6dbac41f8c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling/ConfigMapDependent.java @@ -0,0 +1,25 @@ +package io.javaoperatorsdk.operator.workflow.workflowsilentexceptionhandling; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDNoGCKubernetesDependentResource; + +public class ConfigMapDependent + extends CRUDNoGCKubernetesDependentResource< + ConfigMap, HandleWorkflowExceptionsInReconcilerCustomResource> { + + @Override + public ReconcileResult reconcile( + HandleWorkflowExceptionsInReconcilerCustomResource primary, + Context context) { + throw new RuntimeException("Exception thrown on purpose"); + } + + @Override + public void delete( + HandleWorkflowExceptionsInReconcilerCustomResource primary, + Context context) { + throw new RuntimeException("Exception thrown on purpose"); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling/HandleWorkflowExceptionsInReconcilerCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling/HandleWorkflowExceptionsInReconcilerCustomResource.java new file mode 100644 index 0000000000..e05f73cc6d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling/HandleWorkflowExceptionsInReconcilerCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.workflow.workflowsilentexceptionhandling; + +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("hweir") +public class HandleWorkflowExceptionsInReconcilerCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling/HandleWorkflowExceptionsInReconcilerReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling/HandleWorkflowExceptionsInReconcilerReconciler.java new file mode 100644 index 0000000000..a8cbb11049 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling/HandleWorkflowExceptionsInReconcilerReconciler.java @@ -0,0 +1,59 @@ +package io.javaoperatorsdk.operator.workflow.workflowsilentexceptionhandling; + +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +@Workflow( + handleExceptionsInReconciler = true, + dependents = @Dependent(type = ConfigMapDependent.class)) +@ControllerConfiguration +public class HandleWorkflowExceptionsInReconcilerReconciler + implements Reconciler, + Cleaner { + + private volatile boolean errorsFoundInReconcilerResult = false; + private volatile boolean errorsFoundInCleanupResult = false; + + @Override + public UpdateControl reconcile( + HandleWorkflowExceptionsInReconcilerCustomResource resource, + Context context) { + + errorsFoundInReconcilerResult = + context + .managedWorkflowAndDependentResourceContext() + .getWorkflowReconcileResult() + .orElseThrow() + .erroredDependentsExist(); + + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup( + HandleWorkflowExceptionsInReconcilerCustomResource resource, + Context context) { + + errorsFoundInCleanupResult = + context + .managedWorkflowAndDependentResourceContext() + .getWorkflowCleanupResult() + .orElseThrow() + .erroredDependentsExist(); + return DeleteControl.defaultDelete(); + } + + public boolean isErrorsFoundInReconcilerResult() { + return errorsFoundInReconcilerResult; + } + + public boolean isErrorsFoundInCleanupResult() { + return errorsFoundInCleanupResult; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling/WorkflowSilentExceptionHandlingIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling/WorkflowSilentExceptionHandlingIT.java new file mode 100644 index 0000000000..2f5b7246c6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowsilentexceptionhandling/WorkflowSilentExceptionHandlingIT.java @@ -0,0 +1,46 @@ +package io.javaoperatorsdk.operator.workflow.workflowsilentexceptionhandling; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class WorkflowSilentExceptionHandlingIT { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(HandleWorkflowExceptionsInReconcilerReconciler.class) + .build(); + + @Test + void handleExceptionsInReconciler() { + extension.create(testResource()); + var reconciler = + extension.getReconcilerOfType(HandleWorkflowExceptionsInReconcilerReconciler.class); + + await() + .untilAsserted( + () -> { + assertThat(reconciler.isErrorsFoundInReconcilerResult()).isTrue(); + }); + + extension.delete(testResource()); + + await() + .untilAsserted( + () -> { + assertThat(reconciler.isErrorsFoundInCleanupResult()).isTrue(); + }); + } + + HandleWorkflowExceptionsInReconcilerCustomResource testResource() { + var res = new HandleWorkflowExceptionsInReconcilerCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName("test1").build()); + return res; + } +} diff --git a/operator-framework/src/test/resources/com/github/containersolutions/operator/test-crd.yaml b/operator-framework/src/test/resources/com/github/containersolutions/operator/test-crd.yaml deleted file mode 100644 index 67d2b82ecb..0000000000 --- a/operator-framework/src/test/resources/com/github/containersolutions/operator/test-crd.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1beta1 -kind: CustomResourceDefinition -metadata: - name: customservices.sample.javaoperatorsdk -spec: - group: sample.javaoperatorsdk - version: v1 - scope: Namespaced - names: - plural: customservices - singular: customservice - kind: CustomService - shortNames: - - cs \ No newline at end of file diff --git a/operator-framework/src/test/resources/compile-fixtures/AbstractReconciler.java b/operator-framework/src/test/resources/compile-fixtures/AbstractReconciler.java new file mode 100644 index 0000000000..6f351b845a --- /dev/null +++ b/operator-framework/src/test/resources/compile-fixtures/AbstractReconciler.java @@ -0,0 +1,14 @@ +package io; + +import io.fabric8.kubernetes.client.CustomResource; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import java.io.Serializable; + + +public abstract class AbstractReconciler> implements Serializable, + Reconciler { + + public static class MyCustomResource extends CustomResource { + + } +} diff --git a/operator-framework/src/test/resources/compile-fixtures/AdditionalReconcilerInterface.java b/operator-framework/src/test/resources/compile-fixtures/AdditionalReconcilerInterface.java new file mode 100644 index 0000000000..2579e31c56 --- /dev/null +++ b/operator-framework/src/test/resources/compile-fixtures/AdditionalReconcilerInterface.java @@ -0,0 +1,11 @@ +package io; + +import io.fabric8.kubernetes.client.CustomResource; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import java.io.Serializable; + + +public interface AdditionalReconcilerInterface> extends + Serializable, + Reconciler { +} diff --git a/operator-framework/src/test/resources/compile-fixtures/MultilevelAbstractReconciler.java b/operator-framework/src/test/resources/compile-fixtures/MultilevelAbstractReconciler.java new file mode 100644 index 0000000000..0dafba4c69 --- /dev/null +++ b/operator-framework/src/test/resources/compile-fixtures/MultilevelAbstractReconciler.java @@ -0,0 +1,11 @@ +package io; + +import io.fabric8.kubernetes.client.CustomResource; +import java.io.Serializable; + + +public abstract class MultilevelAbstractReconciler> implements + Serializable, + AdditionalReconcilerInterface { + +} diff --git a/operator-framework/src/test/resources/compile-fixtures/MultilevelReconciler.java b/operator-framework/src/test/resources/compile-fixtures/MultilevelReconciler.java new file mode 100644 index 0000000000..254d211bd0 --- /dev/null +++ b/operator-framework/src/test/resources/compile-fixtures/MultilevelReconciler.java @@ -0,0 +1,28 @@ +package io; + +import io.fabric8.kubernetes.client.CustomResource; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +@ControllerConfiguration +public class MultilevelReconciler extends + MultilevelAbstractReconciler { + + public static class MyCustomResource extends CustomResource { + + } + + public UpdateControl reconcile( + MultilevelReconciler.MyCustomResource customResource, + Context context) { + return UpdateControl.patchResource(null); + } + + public DeleteControl cleanup(MultilevelReconciler.MyCustomResource customResource, + Context context) { + return DeleteControl.defaultDelete(); + } + +} diff --git a/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplemented2Interfaces.java b/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplemented2Interfaces.java new file mode 100644 index 0000000000..bd1ba773be --- /dev/null +++ b/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplemented2Interfaces.java @@ -0,0 +1,24 @@ +package io; + +import io.fabric8.kubernetes.client.CustomResource; +import io.javaoperatorsdk.operator.api.reconciler.*; + +import java.io.Serializable; + +@ControllerConfiguration +public class ReconcilerImplemented2Interfaces implements Serializable, + Reconciler, Cleaner { + + public static class MyCustomResource extends CustomResource { + } + + @Override + public UpdateControl reconcile(MyCustomResource customResource, Context context) { + return UpdateControl.patchResource(null); + } + + @Override + public DeleteControl cleanup(MyCustomResource customResource, Context context) { + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplementedIntermediateAbstractClass.java b/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplementedIntermediateAbstractClass.java new file mode 100644 index 0000000000..ee291cf9ce --- /dev/null +++ b/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplementedIntermediateAbstractClass.java @@ -0,0 +1,23 @@ +package io; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import java.io.Serializable; + +@ControllerConfiguration +public class ReconcilerImplementedIntermediateAbstractClass extends + AbstractReconciler implements Serializable { + + public UpdateControl reconcile( + AbstractReconciler.MyCustomResource customResource, + Context context) { + return UpdateControl.patchResource(null); + } + + public DeleteControl cleanup(AbstractReconciler.MyCustomResource customResource, + Context context) { + return DeleteControl.defaultDelete(); + } +} diff --git a/samples/webserver/src/main/resources/com/github/containersolutions/operator/sample/html-configmap.yaml b/operator-framework/src/test/resources/configmap.yaml similarity index 65% rename from samples/webserver/src/main/resources/com/github/containersolutions/operator/sample/html-configmap.yaml rename to operator-framework/src/test/resources/configmap.yaml index 8314c5b927..3245e257ab 100644 --- a/samples/webserver/src/main/resources/com/github/containersolutions/operator/sample/html-configmap.yaml +++ b/operator-framework/src/test/resources/configmap.yaml @@ -1,6 +1,6 @@ -kind: ConfigMap apiVersion: v1 +kind: ConfigMap metadata: - name: "" + name: "" data: - html: "" \ No newline at end of file + key: "" \ No newline at end of file diff --git a/operator-framework/src/test/resources/crd/test.crd b/operator-framework/src/test/resources/crd/test.crd new file mode 100644 index 0000000000..f10e5b3225 --- /dev/null +++ b/operator-framework/src/test/resources/crd/test.crd @@ -0,0 +1,21 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: tests.crd.example +spec: + group: crd.example + names: + kind: Test + singular: test + plural: tests + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + foo: + type: "string" + type: "object" + served: true + storage: true diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/leader-elector-stop-noaccess-role-binding.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/leader-elector-stop-noaccess-role-binding.yaml new file mode 100644 index 0000000000..0e374522d9 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/leader-elector-stop-noaccess-role-binding.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +# This cluster role binding allows anyone in the "manager" group to read secrets in any namespace. +kind: RoleBinding +metadata: + name: informer-rbac-startup-global + namespace: default +subjects: + - kind: User + name: leader-elector-stop-noaccess + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: Role + name: leader-elector-stop-noaccess + apiGroup: rbac.authorization.k8s.io \ No newline at end of file diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/leader-elector-stop-role-noaccess.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/leader-elector-stop-role-noaccess.yaml new file mode 100644 index 0000000000..7d0b451bc9 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/leader-elector-stop-role-noaccess.yaml @@ -0,0 +1,9 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + namespace: default + name: leader-elector-stop-noaccess +rules: + - apiGroups: [ "" ] + resources: [ "configmaps" ] + verbs: [ "get", "watch", "list","post", "delete", "create","patch" ] \ No newline at end of file diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-full-access-role.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-full-access-role.yaml new file mode 100644 index 0000000000..d5fe6b5862 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-full-access-role.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + # "namespace" omitted since ClusterRoles are not namespaced + name: rbac-behavior +rules: + - apiGroups: [ "sample.javaoperatorsdk" ] + resources: [ "informerrelatedbehaviortestcustomresources" ] + verbs: [ "get", "watch", "list","post", "delete" ] + - apiGroups: [ "" ] + resources: [ "configmaps" ] + verbs: [ "get", "watch", "list","post", "delete", "create","patch" ] + + diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-no-configmap-access.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-no-configmap-access.yaml new file mode 100644 index 0000000000..2afe7e2fdc --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-no-configmap-access.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + # "namespace" omitted since ClusterRoles are not namespaced + name: rbac-behavior +rules: + - apiGroups: [ "sample.javaoperatorsdk" ] + resources: [ "informerrelatedbehaviortestcustomresources" ] + verbs: [ "get", "watch", "list","post", "delete","patch" ] + + diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-no-cr-access.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-no-cr-access.yaml new file mode 100644 index 0000000000..63e9951fda --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-no-cr-access.yaml @@ -0,0 +1,10 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + # "namespace" omitted since ClusterRoles are not namespaced + name: rbac-behavior +rules: + - apiGroups: [""] + resources: [ "configmaps" ] + verbs: [ "get", "watch", "list","post", "delete","create","patch"] + diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-only-main-ns-access-binding.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-only-main-ns-access-binding.yaml new file mode 100644 index 0000000000..ded058531b --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-only-main-ns-access-binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: read-namespace-access +subjects: + - kind: User + name: rbac-test-user + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: Role + name: rbac-behavior + apiGroup: rbac.authorization.k8s.io \ No newline at end of file diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-only-main-ns-access.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-only-main-ns-access.yaml new file mode 100644 index 0000000000..776acfb258 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-only-main-ns-access.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: rbac-behavior +rules: + - apiGroups: [ "sample.javaoperatorsdk" ] + resources: [ "informerrelatedbehaviortestcustomresources" ] + verbs: [ "get", "watch", "list","post", "delete" ] + - apiGroups: [ "" ] + resources: [ "configmaps" ] + verbs: [ "get", "watch", "list","post", "delete", "create","patch" ] \ No newline at end of file diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-role-binding.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-role-binding.yaml new file mode 100644 index 0000000000..ec83726617 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/dependent/informerrelatedbehavior/rbac-test-role-binding.yaml @@ -0,0 +1,13 @@ +apiVersion: rbac.authorization.k8s.io/v1 +# This cluster role binding allows anyone in the "manager" group to read secrets in any namespace. +kind: ClusterRoleBinding +metadata: + name: informer-rbac-startup-global +subjects: + - kind: User + name: rbac-test-user + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: ClusterRole + name: rbac-behavior + apiGroup: rbac.authorization.k8s.io \ No newline at end of file diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/nginx-deployment.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/nginx-deployment.yaml new file mode 100644 index 0000000000..cea0f3c9d6 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/nginx-deployment.yaml @@ -0,0 +1,21 @@ +apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2 +kind: Deployment +metadata: + name: "" +spec: + progressDeadlineSeconds: 600 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: "test-dependent" + replicas: 1 + template: + metadata: + labels: + app: "test-dependent" + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/sample/complexdependent/dependent/service.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/sample/complexdependent/dependent/service.yaml new file mode 100644 index 0000000000..736ef33178 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/sample/complexdependent/dependent/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: mongo-template + labels: + app.kubernetes.io/name: mongo-template + app.kubernetes.io/component: first-or-second +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: mongo-template + clusterIP: None + ports: + - name: mongodb + port: 27017 + targetPort: 27017 \ No newline at end of file diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/sample/complexdependent/dependent/statefulset.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/sample/complexdependent/dependent/statefulset.yaml new file mode 100644 index 0000000000..a335a4aec7 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/sample/complexdependent/dependent/statefulset.yaml @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: mongo-template + labels: + app.kubernetes.io/name: mongo-template + app.kubernetes.io/component: first-or-second +spec: + serviceName: mongo-template + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: mongo-template + template: + metadata: + labels: + app.kubernetes.io/name: mongo-template + app.kubernetes.io/component: first-or-second + spec: + terminationGracePeriodSeconds: 10 + containers: + - name: mongo + image: mongo:4.4 + ports: + - name: mongodb + containerPort: 27017 + volumeMounts: + - name: data + mountPath: /data/db + volumeClaimTemplates: + - apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: data + labels: + app.kubernetes.io/name: mongo-template + spec: + volumeMode: Filesystem + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + limits: + storage: 50Gi + diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/service-template.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/service-template.yaml new file mode 100644 index 0000000000..de91b201ef --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/service-template.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: my-service +spec: + selector: + app.kubernetes.io/name: MyApp + ports: + - protocol: TCP + port: 80 + targetPort: 9376 \ No newline at end of file diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/service.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/service.yaml new file mode 100644 index 0000000000..b9ef3b7b3d --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: "" +spec: + selector: + app: "" + ports: + - protocol: TCP + port: 80 + targetPort: 80 + type: NodePort \ No newline at end of file diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/statefulset.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/statefulset.yaml new file mode 100644 index 0000000000..bb8a2df04b --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/statefulset.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "" +spec: + selector: + matchLabels: + app: nginx # has to match .spec.template.metadata.labels + serviceName: "nginx" + replicas: 1 + minReadySeconds: 10 # by default is 0 + template: + metadata: + labels: + app: nginx # has to match .spec.selector.matchLabels + spec: + terminationGracePeriodSeconds: 10 + containers: + - name: nginx + image: registry.k8s.io/nginx-slim:0.8 + ports: + - containerPort: 80 + name: web + env: + - name: APP1_HOST_NAME + value: "" + - name: APP2_HOST_NAME + value: "localhost" + - name: APP3_HOST_NAME + value: " " + volumeMounts: + - name: www + mountPath: /usr/share/nginx/html + volumeClaimTemplates: + - metadata: + name: www + spec: + accessModes: [ "ReadWriteOnce" ] + storageClassName: "my-storage-class" + resources: + requests: + storage: 1Gi \ No newline at end of file diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/workflow/complexdependent/service.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/workflow/complexdependent/service.yaml new file mode 100644 index 0000000000..736ef33178 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/workflow/complexdependent/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: mongo-template + labels: + app.kubernetes.io/name: mongo-template + app.kubernetes.io/component: first-or-second +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: mongo-template + clusterIP: None + ports: + - name: mongodb + port: 27017 + targetPort: 27017 \ No newline at end of file diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/workflow/complexdependent/statefulset.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/workflow/complexdependent/statefulset.yaml new file mode 100644 index 0000000000..a335a4aec7 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/workflow/complexdependent/statefulset.yaml @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: mongo-template + labels: + app.kubernetes.io/name: mongo-template + app.kubernetes.io/component: first-or-second +spec: + serviceName: mongo-template + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: mongo-template + template: + metadata: + labels: + app.kubernetes.io/name: mongo-template + app.kubernetes.io/component: first-or-second + spec: + terminationGracePeriodSeconds: 10 + containers: + - name: mongo + image: mongo:4.4 + ports: + - name: mongodb + containerPort: 27017 + volumeMounts: + - name: data + mountPath: /data/db + volumeClaimTemplates: + - apiVersion: v1 + kind: PersistentVolumeClaim + metadata: + name: data + labels: + app.kubernetes.io/name: mongo-template + spec: + volumeMode: Filesystem + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi + limits: + storage: 50Gi + diff --git a/operator-framework/src/test/resources/log4j2.xml b/operator-framework/src/test/resources/log4j2.xml index 646f6eeea5..82d8fa2cb1 100644 --- a/operator-framework/src/test/resources/log4j2.xml +++ b/operator-framework/src/test/resources/log4j2.xml @@ -1,14 +1,16 @@ - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/perform-release.sh b/perform-release.sh deleted file mode 100755 index 3462b5e498..0000000000 --- a/perform-release.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -BRANCH=$(git rev-parse --abbrev-ref HEAD) -if [ "$BRANCH" != "master" ]; then - echo "Only run releases from master branch (current is '$BRANCH')" - exit 0 -fi - -echo "Setting version to next release version" -mvn -q build-helper:parse-version versions:set -DnewVersion=\${parsedVersion.majorVersion}.\${parsedVersion.minorVersion}.\${parsedVersion.incrementalVersion} -find . -name 'pom.xml' | xargs git add - -RELEASE_VERSION=$(mvn -q -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive exec:exec) - -read -p "Finish release of ${RELEASE_VERSION} by pushing it to Git (y/n)?" choice -if [[ $choice == 'y' ]]; then - mvn -q versions:commit - git commit -m "release version ${RELEASE_VERSION}" - echo "git push" - git push - - echo "Have to wait until TravisCI finishes the release build, otherwise it will cancel it" - read -p "Is TravisCI finished with the release build (https://travis-ci.org/ContainerSolutions/java-operator-sdk/) (y/n)?" finished - if [[ $finished == 'y' ]]; then - echo "Setting new SNAPSHOT version" - mvn -q build-helper:parse-version versions:set -DnewVersion=\${parsedVersion.majorVersion}.\${parsedVersion.minorVersion}.\${parsedVersion.nextIncrementalVersion}-SNAPSHOT versions:commit - NEW_VERSION=$(mvn -q -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive exec:exec) - find . -name 'pom.xml' | xargs git add - git commit -m "set SNAPSHOT version: ${NEW_VERSION}" - echo "git push" - git push - echo "Finished release. New SNAPSHOT version is ${NEW_VERSION}" - fi -else - echo "Reverting version" - mvn -q versions:revert -fi diff --git a/pom.xml b/pom.xml index 1649d306ae..303c11f79b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,184 +1,558 @@ - - 4.0.0 + + 4.0.0 - com.github.containersolutions - java-operator-sdk - 0.3.10-SNAPSHOT - Operator SDK for Java - Java SDK for implementing Kubernetes operators - pom - https://github.com/ContainerSolutions/java-operator-sdk + io.javaoperatorsdk + java-operator-sdk + 5.1.5-SNAPSHOT + pom + Operator SDK for Java + Java SDK for implementing Kubernetes operators + https://github.com/operator-framework/java-operator-sdk - - - Apache 2 License - https://www.apache.org/licenses/LICENSE-2.0.html - - - - - Adam Sandor - adam.sandor@container-solutions.com - - - Attila Meszaros - attila.meszaros@container-solutions.com - - + + + Apache 2 License + https://www.apache.org/licenses/LICENSE-2.0.html + + + + + Adam Sandor + adam.sandor@container-solutions.com + + + Attila Meszaros + csviri@gmail.com + + - - scm:git:git://github.com/ContainerSolutions/java-operator-sdk.git - scm:git:git@github.com/ContainerSolutions/java-operator-sdk.git - https://github.com/ContainerSolutions/java-operator-sdk/tree/master - + + operator-framework-bom + operator-framework-core + operator-framework-junit5 + operator-framework + micrometer-support + sample-operators + caffeine-bounded-cache-support + bootstrapper-maven-plugin + - - operator-framework - spring-boot-starter - samples - + + scm:git:git://github.com/operator-framework/java-operator-sdk.git + scm:git:git@github.com/operator-framework/java-operator-sdk.git + https://github.com/operator-framework/java-operator-sdk/tree/main + - - - - com.google.guava - guava - 28.1-jre - - - io.fabric8 - openshift-client - 4.7.1 - - - org.apache.commons - commons-lang3 - 3.8.1 - - - org.slf4j - slf4j-api - 1.7.26 - - - org.junit.jupiter - junit-jupiter-engine - 5.4.2 - test - - - org.apache.logging.log4j - log4j-slf4j-impl - 2.11.2 - - - org.mockito - mockito-core - 3.0.0 - test - + + UTF-8 + 17 + ${java.version} + ${java.version} + java-operator-sdk + https://sonarcloud.io + jdk + 5.13.4 + 7.3.1 + 2.0.17 + 2.25.2 + 5.20.0 + 3.19.0 + 0.23.0 + 1.13.0 + 3.27.6 + 4.3.0 + 2.7.3 + 1.15.5 + 3.2.2 + 0.9.14 + 2.20.0 + 4.16 + + 2.11 + 3.14.1 + 3.5.4 + 0.9.0 + 3.12.0 + 3.3.1 + 3.3.1 + 3.4.2 + 3.5.0 + 3.2.8 + 1.7.0 + 3.0.0 + 3.1.4 + 9.0.2 + 3.4.6 + 3.0.0 + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + io.fabric8 + kubernetes-client-bom + ${fabric8-client.version} + pom + import + + + io.fabric8 + kubernetes-server-mock + ${fabric8-client.version} + test + + + io.fabric8 + kubernetes-client-api + ${fabric8-client.version} + + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + + + com.google.testing.compile + compile-testing + ${compile-testing.version} + + + io.micrometer + micrometer-core + ${micrometer-core.version} + + + com.squareup + javapoet + ${javapoet.version} + + + org.awaitility + awaitility + ${awaitility.version} + + + commons-io + commons-io + ${commons.io.version} + + + org.assertj + assertj-core + ${assertj.version} + + + org.mockito + mockito-core + ${mokito.version} + + + io.github.java-diff-utils + java-diff-utils + ${java.diff.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.apache.logging.log4j + log4j-slf4j2-impl + ${log4j.version} + + + org.apache.logging.log4j + log4j-core + ${log4j.version} + test + + + org.apache.logging.log4j + log4j2-core + ${log4j.version} + + + com.github.spullara.mustache.java + compiler + ${mustache.version} + + + io.javaoperatorsdk + operator-framework-core + ${project.version} + + + io.javaoperatorsdk + operator-framework + ${project.version} + + + com.github.ben-manes.caffeine + caffeine + ${caffeine.version} + + + io.fabric8 + kube-api-test-client-inject + ${fabric8-client.version} + + + + + io.fabric8 + kubernetes-httpclient-okhttp + ${fabric8-client.version} + + + io.fabric8 + kubernetes-httpclient-vertx + ${fabric8-client.version} + + + io.fabric8 + kubernetes-httpclient-jdk + ${fabric8-client.version} + + + io.fabric8 + kubernetes-httpclient-jetty + ${fabric8-client.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.apache.maven.plugins + maven-resources-plugin + ${maven-resources-plugin.version} + + + org.apache.maven.plugins + maven-jar-plugin + ${maven-jar-plugin.version} + + + org.apache.maven.plugins + maven-clean-plugin + ${maven-clean-plugin.version} + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + plain + + true + + + UNICODE + true + true + true + true + false + true + true + false + + + - org.springframework - spring-core - 5.1.8.RELEASE - compile + me.fabriciorby + maven-surefire-junit5-tree-reporter + 1.5.1 - - - - - - ossrh - https://oss.sonatype.org/content/repositories/snapshots - - + + + + org.apache.maven.plugins + maven-source-plugin + ${maven-source-plugin.version} + + + attach-sources + + jar + + verify + + + + + org.apache.maven.plugins + maven-gpg-plugin + ${maven-gpg-plugin.version} + + + org.apache.maven.plugins + maven-install-plugin + ${maven-install-plugin.version} + + + com.diffplug.spotless + spotless-maven-plugin + ${spotless.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*Test.java + + + **/*IT.java + **/*E2E.java + + WatchPermissionAwareTest + + + + com.diffplug.spotless + spotless-maven-plugin + + + + pom.xml + ./**/pom.xml + + + false + + + + + 1.28.0 + true + + + java,javax,org,io,com,,\# + + + + + + + + apply + + compile + + + - + + + + + integration-tests + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*IT.java + + + **/*Test.java + **/*E2E.java + + + + + + + + integration-tests-baseapi + + + + org.apache.maven.plugins + maven-surefire-plugin + + + io/javaoperatorsdk/operator/baseapi/**/*IT.java + + + **/*Test.java + **/*E2E.java + + + + + + + + integration-tests-dependent + + + + org.apache.maven.plugins + maven-surefire-plugin + + + io/javaoperatorsdk/operator/dependent/**/*IT.java + + + **/*Test.java + **/*E2E.java + + + + + + + + integration-tests-workflow + - - org.apache.maven.plugins - maven-surefire-plugin - + + org.apache.maven.plugins + maven-surefire-plugin + + + io/javaoperatorsdk/operator/workflow/**/*IT.java + + + **/*Test.java + **/*E2E.java + + + + + + + + + minimal-watch-timeout-dependent-it + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*ITS.java + + + **/*Test.java + **/*E2E.java + **/*IT.java + + + + + + + + end-to-end-tests + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*E2E.java + + + **/*Test.java + **/*IT.java + + + + + + + + release + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*IT.java + **/*E2E.java + **/InformerRelatedBehaviorTest.java + + + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven-javadoc-plugin.version} + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-gpg-plugin + + + sign-artifacts + + sign + + verify - - **/*IT.java - + + --pinentry-mode + loopback + - + + + + + org.sonatype.central + central-publishing-maven-plugin + ${central-publishing-maven-plugin.version} + true + + central + true + true + published + + - - - - - release - - - - org.apache.maven.plugins - maven-surefire-plugin - - - - **/*IT.java - - - - - org.apache.maven.plugins - maven-javadoc-plugin - 3.0.0 - - - attach-javadocs - - jar - - - - - - org.apache.maven.plugins - maven-source-plugin - 3.0.1 - - - attach-sources - - jar - - - - - - org.apache.maven.plugins - maven-gpg-plugin - 1.6 - - - sign-artifacts - verify - - sign - - - - - - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.8 - true - - ossrh - https://oss.sonatype.org/ - true - - - - - - - + + + diff --git a/sample-operators/controller-namespace-deletion/README.md b/sample-operators/controller-namespace-deletion/README.md new file mode 100644 index 0000000000..3ea02d1d36 --- /dev/null +++ b/sample-operators/controller-namespace-deletion/README.md @@ -0,0 +1,8 @@ +This sample demonstrates the workaround for problem when a namespace +is being deleted with a running controller, that watches resources +in its own namespace. If the pod or other underlying resources (role, +role binding, service account) are deleted before the cleanup of +the custom resource the namespace deletion is stuck. + +see also: https://github.com/operator-framework/java-operator-sdk/pull/2528 + diff --git a/sample-operators/controller-namespace-deletion/k8s/operator.yaml b/sample-operators/controller-namespace-deletion/k8s/operator.yaml new file mode 100644 index 0000000000..bc9eeb84ed --- /dev/null +++ b/sample-operators/controller-namespace-deletion/k8s/operator.yaml @@ -0,0 +1,62 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: operator + finalizers: + - controller.deletion/finalizer + +--- +apiVersion: v1 +kind: Pod +metadata: + name: operator +spec: + serviceAccountName: operator + containers: + - name: operator + image: controller-namespace-deletion-operator + imagePullPolicy: Never + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + terminationGracePeriodSeconds: 30 + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: operator + finalizers: + - controller.deletion/finalizer +subjects: + - kind: ServiceAccount + name: operator +roleRef: + kind: Role + name: operator + apiGroup: rbac.authorization.k8s.io + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: operator + finalizers: + - controller.deletion/finalizer +rules: + - apiGroups: + - "apiextensions.k8s.io" + resources: + - customresourcedefinitions + verbs: + - '*' + - apiGroups: + - "namespacedeletion.io" + resources: + - controllernamespacedeletioncustomresources + - controllernamespacedeletioncustomresources/status + verbs: + - '*' + diff --git a/sample-operators/controller-namespace-deletion/pom.xml b/sample-operators/controller-namespace-deletion/pom.xml new file mode 100644 index 0000000000..bb2a8fe099 --- /dev/null +++ b/sample-operators/controller-namespace-deletion/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + + io.javaoperatorsdk + sample-operators + 5.1.5-SNAPSHOT + + + sample-controller-namespace-deletion + jar + Operator SDK - Samples - Controller Namespace Deletion + Deleting namespace with controller and custom resources + + + + + io.javaoperatorsdk + operator-framework-bom + ${project.version} + pom + import + + + + + + + io.javaoperatorsdk + operator-framework + + + org.apache.logging.log4j + log4j-slf4j2-impl + compile + + + org.apache.logging.log4j + log4j-core + compile + + + org.takes + takes + 1.24.6 + + + org.awaitility + awaitility + compile + + + io.javaoperatorsdk + operator-framework-junit-5 + test + + + org.junit.jupiter + junit-jupiter-params + test + + + + + + com.google.cloud.tools + jib-maven-plugin + ${jib-maven-plugin.version} + + + gcr.io/distroless/java17-debian11 + + + controller-namespace-deletion-operator + + + + + + + diff --git a/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionCustomResource.java b/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionCustomResource.java new file mode 100644 index 0000000000..59bb0eb8b9 --- /dev/null +++ b/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionCustomResource.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.sample; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("namespacedeletion.io") +@Version("v1") +public class ControllerNamespaceDeletionCustomResource + extends CustomResource + implements Namespaced {} diff --git a/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionOperator.java b/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionOperator.java new file mode 100644 index 0000000000..70ee3e60a4 --- /dev/null +++ b/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionOperator.java @@ -0,0 +1,55 @@ +package io.javaoperatorsdk.operator.sample; + +import java.time.LocalTime; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; + +import static java.time.temporal.ChronoUnit.SECONDS; + +public class ControllerNamespaceDeletionOperator { + + private static final Logger log = + LoggerFactory.getLogger(ControllerNamespaceDeletionOperator.class); + + public static void main(String[] args) { + + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + log.info("Shutting down..."); + boolean allResourcesDeleted = waitUntilResourcesDeleted(); + log.info("All resources within timeout: {}", allResourcesDeleted); + })); + + Operator operator = new Operator(); + operator.register( + new ControllerNamespaceDeletionReconciler(), + ControllerConfigurationOverrider::watchingOnlyCurrentNamespace); + operator.start(); + } + + private static boolean waitUntilResourcesDeleted() { + try (var client = new KubernetesClientBuilder().build()) { + var startTime = LocalTime.now(); + while (startTime.until(LocalTime.now(), SECONDS) < 20) { + var items = + client + .resources(ControllerNamespaceDeletionCustomResource.class) + .inNamespace(client.getConfiguration().getNamespace()) + .list() + .getItems(); + log.info("Custom resource in namespace: {}", items); + if (items.isEmpty()) { + return true; + } + } + return false; + } + } +} diff --git a/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionReconciler.java b/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionReconciler.java new file mode 100644 index 0000000000..688f3d8b3d --- /dev/null +++ b/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionReconciler.java @@ -0,0 +1,63 @@ +package io.javaoperatorsdk.operator.sample; + +import java.time.Duration; + +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; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +public class ControllerNamespaceDeletionReconciler + implements Reconciler, + Cleaner { + + private static final Logger log = + LoggerFactory.getLogger(ControllerNamespaceDeletionReconciler.class); + + public static final Duration CLEANUP_DELAY = Duration.ofSeconds(10); + + @Override + public UpdateControl reconcile( + ControllerNamespaceDeletionCustomResource resource, + Context context) { + log.info( + "Reconciling: {} in namespace: {}", + resource.getMetadata().getName(), + resource.getMetadata().getNamespace()); + + var response = createResponseResource(resource); + response.getStatus().setValue(resource.getSpec().getValue()); + + return UpdateControl.patchStatus(response); + } + + private ControllerNamespaceDeletionCustomResource createResponseResource( + ControllerNamespaceDeletionCustomResource resource) { + var res = new ControllerNamespaceDeletionCustomResource(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .build()); + res.setStatus(new ControllerNamespaceDeletionStatus()); + return res; + } + + @Override + public DeleteControl cleanup( + ControllerNamespaceDeletionCustomResource resource, + Context context) { + log.info("Cleaning up resource"); + try { + Thread.sleep(CLEANUP_DELAY.toMillis()); + return DeleteControl.defaultDelete(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionSpec.java b/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionSpec.java new file mode 100644 index 0000000000..0107449eb8 --- /dev/null +++ b/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.sample; + +public class ControllerNamespaceDeletionSpec { + + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionStatus.java b/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionStatus.java new file mode 100644 index 0000000000..36db2f33c4 --- /dev/null +++ b/sample-operators/controller-namespace-deletion/src/main/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionStatus.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.sample; + +public class ControllerNamespaceDeletionStatus { + + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/sample-operators/controller-namespace-deletion/src/main/resources/log4j2.xml b/sample-operators/controller-namespace-deletion/src/main/resources/log4j2.xml new file mode 100644 index 0000000000..0ec69bf713 --- /dev/null +++ b/sample-operators/controller-namespace-deletion/src/main/resources/log4j2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/sample-operators/controller-namespace-deletion/src/test/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionE2E.java b/sample-operators/controller-namespace-deletion/src/test/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionE2E.java new file mode 100644 index 0000000000..e3900a040d --- /dev/null +++ b/sample-operators/controller-namespace-deletion/src/test/java/io/javaoperatorsdk/operator/sample/ControllerNamespaceDeletionE2E.java @@ -0,0 +1,159 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.NamespaceBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.rbac.RoleBinding; +import io.fabric8.kubernetes.client.ConfigBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; + +import static io.javaoperatorsdk.operator.junit.AbstractOperatorExtension.CRD_READY_WAIT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class ControllerNamespaceDeletionE2E { + + private static final Logger log = LoggerFactory.getLogger(ControllerNamespaceDeletionE2E.class); + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String INITIAL_VALUE = "initial value"; + public static final String ROLE_ROLE_BINDING_FINALIZER = "controller.deletion/finalizer"; + public static final String RESOURCE_NAME = "operator"; + + String namespace; + KubernetesClient client; + + // not for local mode by design + @EnabledIfSystemProperty(named = "test.deployment", matches = "remote") + @Test + void customResourceCleanedUpOnNamespaceDeletion() { + deployController(); + client.resource(testResource()).serverSideApply(); + + await() + .untilAsserted( + () -> { + var res = + client + .resources(ControllerNamespaceDeletionCustomResource.class) + .inNamespace(namespace) + .withName(TEST_RESOURCE_NAME) + .get(); + assertThat(res.getStatus()).isNotNull(); + assertThat(res.getStatus().getValue()).isEqualTo(INITIAL_VALUE); + }); + + client.namespaces().withName(namespace).delete(); + + await() + .timeout(Duration.ofSeconds(20)) + .untilAsserted( + () -> { + var ns = + client + .resources(ControllerNamespaceDeletionCustomResource.class) + .inNamespace(namespace) + .withName(TEST_RESOURCE_NAME) + .get(); + assertThat(ns).isNull(); + }); + + log.info("Removing finalizers from role and role bing and service account"); + removeRoleAndRoleBindingFinalizers(); + + await() + .timeout(Duration.ofSeconds(20)) + .untilAsserted( + () -> { + var ns = client.namespaces().withName(namespace).get(); + assertThat(ns).isNull(); + }); + } + + private void removeRoleAndRoleBindingFinalizers() { + var rolebinding = + client.rbac().roleBindings().inNamespace(namespace).withName(RESOURCE_NAME).get(); + rolebinding.getFinalizers().clear(); + client.resource(rolebinding).update(); + + var role = client.rbac().roles().inNamespace(namespace).withName(RESOURCE_NAME).get(); + role.getFinalizers().clear(); + client.resource(role).update(); + + var sa = client.serviceAccounts().inNamespace(namespace).withName(RESOURCE_NAME).get(); + sa.getMetadata().getFinalizers().clear(); + client.resource(sa).update(); + } + + ControllerNamespaceDeletionCustomResource testResource() { + var cr = new ControllerNamespaceDeletionCustomResource(); + cr.setMetadata( + new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).withNamespace(namespace).build()); + cr.setSpec(new ControllerNamespaceDeletionSpec()); + cr.getSpec().setValue(INITIAL_VALUE); + return cr; + } + + @BeforeEach + void setup() { + namespace = "controller-namespace-" + UUID.randomUUID(); + client = + new KubernetesClientBuilder() + .withConfig(new ConfigBuilder().withNamespace(namespace).build()) + .build(); + applyCRD(); + client + .namespaces() + .resource( + new NamespaceBuilder().withNewMetadata().withName(namespace).endMetadata().build()) + .create(); + } + + void deployController() { + try { + List resources = client.load(new FileInputStream("k8s/operator.yaml")).items(); + resources.forEach( + hm -> { + hm.getMetadata().setNamespace(namespace); + if (hm.getKind().equalsIgnoreCase("rolebinding")) { + var crb = (RoleBinding) hm; + for (var subject : crb.getSubjects()) { + subject.setNamespace(namespace); + } + } + }); + client.resourceList(resources).inNamespace(namespace).createOrReplace(); + + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } + } + + void applyCRD() { + String path = + "target/classes/META-INF/fabric8/controllernamespacedeletioncustomresources.namespacedeletion.io-v1.yml"; + try (InputStream is = new FileInputStream(path)) { + final var crd = client.load(is); + crd.serverSideApply(); + Thread.sleep(CRD_READY_WAIT); + log.debug("Applied CRD with name: {}", crd.get().get(0).getMetadata().getName()); + } catch (InterruptedException | IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/samples/webserver/src/main/resources/log4j2.xml b/sample-operators/controller-namespace-deletion/src/test/resources/log4j2.xml similarity index 84% rename from samples/webserver/src/main/resources/log4j2.xml rename to sample-operators/controller-namespace-deletion/src/test/resources/log4j2.xml index 5b794e7de3..2b7fdd3479 100644 --- a/samples/webserver/src/main/resources/log4j2.xml +++ b/sample-operators/controller-namespace-deletion/src/test/resources/log4j2.xml @@ -2,7 +2,7 @@ - + diff --git a/sample-operators/leader-election/README.md b/sample-operators/leader-election/README.md new file mode 100644 index 0000000000..d74bad0b35 --- /dev/null +++ b/sample-operators/leader-election/README.md @@ -0,0 +1,10 @@ +# Leader Election E2E Test + +The purpose of this module is to e2e test leader election feature and to demonstrate contract-first CRDs. + +The deployment is using directly pods in order to better control some aspects in test. +In real life this would be a Deployment. + +The custom resource definition (CRD) is defined in YAML in the folder `src/main/resources/kubernetes`. +Upon build, the [java-generator-maven-plugin](https://github.com/fabric8io/kubernetes-client/blob/master/doc/java-generation-from-CRD.md) +generates the Java code under `target/generated-sources/java`. diff --git a/sample-operators/leader-election/k8s/namespace-inferred-operator-instance-2.yaml b/sample-operators/leader-election/k8s/namespace-inferred-operator-instance-2.yaml new file mode 100644 index 0000000000..b8f09681e7 --- /dev/null +++ b/sample-operators/leader-election/k8s/namespace-inferred-operator-instance-2.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: leader-election-operator-2 +spec: + serviceAccountName: leader-election-operator + containers: + - name: operator + image: leader-election-operator + imagePullPolicy: Never + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name diff --git a/sample-operators/leader-election/k8s/namespace-inferred-operator.yaml b/sample-operators/leader-election/k8s/namespace-inferred-operator.yaml new file mode 100644 index 0000000000..cf8e743bd2 --- /dev/null +++ b/sample-operators/leader-election/k8s/namespace-inferred-operator.yaml @@ -0,0 +1,60 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: leader-election-operator + +--- +apiVersion: v1 +kind: Pod +metadata: + name: leader-election-operator-1 +spec: + serviceAccountName: leader-election-operator + containers: + - name: operator + image: leader-election-operator + imagePullPolicy: Never + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: operator-admin +subjects: + - kind: ServiceAccount + name: leader-election-operator +roleRef: + kind: ClusterRole + name: leader-election-operator + apiGroup: "" + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: leader-election-operator +rules: + - apiGroups: + - "apiextensions.k8s.io" + resources: + - customresourcedefinitions + verbs: + - '*' + - apiGroups: + - "sample.operator.javaoperatorsdk.io" + resources: + - leaderelections + - leaderelections/status + verbs: + - '*' + - apiGroups: + - "coordination.k8s.io" + resources: + - "leases" + verbs: + - '*' diff --git a/sample-operators/leader-election/k8s/operator-instance-2.yaml b/sample-operators/leader-election/k8s/operator-instance-2.yaml new file mode 100644 index 0000000000..abde7eb56a --- /dev/null +++ b/sample-operators/leader-election/k8s/operator-instance-2.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Pod +metadata: + name: leader-election-operator-2 +spec: + serviceAccountName: leader-election-operator + containers: + - name: operator + image: leader-election-operator + imagePullPolicy: Never + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name diff --git a/sample-operators/leader-election/k8s/operator.yaml b/sample-operators/leader-election/k8s/operator.yaml new file mode 100644 index 0000000000..eea9348072 --- /dev/null +++ b/sample-operators/leader-election/k8s/operator.yaml @@ -0,0 +1,65 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: leader-election-operator + +--- +apiVersion: v1 +kind: Pod +metadata: + name: leader-election-operator-1 +spec: + serviceAccountName: leader-election-operator + containers: + - name: operator + image: leader-election-operator + imagePullPolicy: Never + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: operator-admin +subjects: + - kind: ServiceAccount + name: leader-election-operator +roleRef: + kind: ClusterRole + name: leader-election-operator + apiGroup: "" + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: leader-election-operator +rules: + - apiGroups: + - "apiextensions.k8s.io" + resources: + - customresourcedefinitions + verbs: + - '*' + - apiGroups: + - "sample.operator.javaoperatorsdk.io" + resources: + - leaderelections + - leaderelections/status + verbs: + - '*' + - apiGroups: + - "coordination.k8s.io" + resources: + - "leases" + verbs: + - '*' + diff --git a/sample-operators/leader-election/pom.xml b/sample-operators/leader-election/pom.xml new file mode 100644 index 0000000000..23873c5d45 --- /dev/null +++ b/sample-operators/leader-election/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + + io.javaoperatorsdk + sample-operators + 5.1.5-SNAPSHOT + + + sample-leader-election + jar + Operator SDK - Samples - Leader Election + An E2E test for leader election + + + + + io.javaoperatorsdk + operator-framework-bom + ${project.version} + pom + import + + + + + + + io.javaoperatorsdk + operator-framework + + + org.apache.logging.log4j + log4j-slf4j2-impl + compile + + + org.apache.logging.log4j + log4j-core + compile + + + org.takes + takes + 1.24.6 + + + org.awaitility + awaitility + compile + + + io.javaoperatorsdk + operator-framework-junit-5 + test + + + org.junit.jupiter + junit-jupiter-params + test + + + + + + com.google.cloud.tools + jib-maven-plugin + ${jib-maven-plugin.version} + + + gcr.io/distroless/java17-debian11 + + + leader-election-operator + + + + + + io.fabric8 + java-generator-maven-plugin + ${fabric8-client.version} + + src/main/resources/kubernetes + + + + + generate + + + + + + + + diff --git a/sample-operators/leader-election/src/main/java/io/javaoperatorsdk/operator/sample/LeaderElectionTestOperator.java b/sample-operators/leader-election/src/main/java/io/javaoperatorsdk/operator/sample/LeaderElectionTestOperator.java new file mode 100644 index 0000000000..359272e0ef --- /dev/null +++ b/sample-operators/leader-election/src/main/java/io/javaoperatorsdk/operator/sample/LeaderElectionTestOperator.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.sample; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration; + +public class LeaderElectionTestOperator { + + private static final Logger log = LoggerFactory.getLogger(LeaderElectionTestOperator.class); + + public static void main(String[] args) { + String identity = System.getenv("POD_NAME"); + String namespace = System.getenv("POD_NAMESPACE"); + + log.info("Starting operator with identity: {}", identity); + + LeaderElectionConfiguration leaderElectionConfiguration = + namespace == null + ? new LeaderElectionConfiguration("leader-election-test") + : new LeaderElectionConfiguration("leader-election-test", namespace, identity); + + Operator operator = + new Operator(c -> c.withLeaderElectionConfiguration(leaderElectionConfiguration)); + + operator.register(new LeaderElectionTestReconciler(identity)); + operator.start(); + } +} diff --git a/sample-operators/leader-election/src/main/java/io/javaoperatorsdk/operator/sample/LeaderElectionTestReconciler.java b/sample-operators/leader-election/src/main/java/io/javaoperatorsdk/operator/sample/LeaderElectionTestReconciler.java new file mode 100644 index 0000000000..5f8d0aa911 --- /dev/null +++ b/sample-operators/leader-election/src/main/java/io/javaoperatorsdk/operator/sample/LeaderElectionTestReconciler.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.sample; + +import java.time.Duration; +import java.util.ArrayList; + +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.sample.v1.LeaderElection; +import io.javaoperatorsdk.operator.sample.v1.LeaderElectionStatus; + +@ControllerConfiguration() +public class LeaderElectionTestReconciler implements Reconciler { + + private final String reconcilerName; + + public LeaderElectionTestReconciler(String reconcilerName) { + this.reconcilerName = reconcilerName; + } + + @Override + public UpdateControl reconcile( + LeaderElection resource, Context context) { + + if (resource.getStatus() == null) { + resource.setStatus(new LeaderElectionStatus()); + } + if (resource.getStatus().getReconciledBy() == null) { + resource.getStatus().setReconciledBy(new ArrayList<>()); + } + + resource.getStatus().getReconciledBy().add(reconcilerName); + // update status is with optimistic locking + return UpdateControl.patchStatus(resource).rescheduleAfter(Duration.ofSeconds(1)); + } +} diff --git a/sample-operators/leader-election/src/main/resources/kubernetes/leaderelections.sample.javaoperatorsdk-v1.yml b/sample-operators/leader-election/src/main/resources/kubernetes/leaderelections.sample.javaoperatorsdk-v1.yml new file mode 100644 index 0000000000..e0580f8901 --- /dev/null +++ b/sample-operators/leader-election/src/main/resources/kubernetes/leaderelections.sample.javaoperatorsdk-v1.yml @@ -0,0 +1,34 @@ +# Custom Resource Definition that will be used to generate the Java classes in target/generated-sources/java +# See https://github.com/fabric8io/kubernetes-client/blob/master/doc/java-generation-from-CRD.md +# The Java classes will then be used to recreate this CR in target/classes/META-INF/fabric8 +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: leaderelections.sample.operator.javaoperatorsdk.io +spec: + group: sample.operator.javaoperatorsdk.io + names: + kind: LeaderElection + singular: leaderelection + plural: leaderelections + shortNames: + - le + - les + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + properties: + status: + properties: + reconciledBy: + items: + type: string + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/sample-operators/leader-election/src/main/resources/log4j2.xml b/sample-operators/leader-election/src/main/resources/log4j2.xml new file mode 100644 index 0000000000..0ec69bf713 --- /dev/null +++ b/sample-operators/leader-election/src/main/resources/log4j2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/sample-operators/leader-election/src/test/java/io/javaoperatorsdk/operator/sample/LeaderElectionE2E.java b/sample-operators/leader-election/src/test/java/io/javaoperatorsdk/operator/sample/LeaderElectionE2E.java new file mode 100644 index 0000000000..60c4fb9dc1 --- /dev/null +++ b/sample-operators/leader-election/src/test/java/io/javaoperatorsdk/operator/sample/LeaderElectionE2E.java @@ -0,0 +1,222 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.OptionalInt; +import java.util.UUID; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.NamespaceBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.rbac.ClusterRoleBinding; +import io.fabric8.kubernetes.client.ConfigBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.javaoperatorsdk.operator.sample.v1.LeaderElection; + +import static io.javaoperatorsdk.operator.junit.AbstractOperatorExtension.CRD_READY_WAIT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class LeaderElectionE2E { + + private static final Logger log = LoggerFactory.getLogger(LeaderElectionE2E.class); + public static final int POD_STARTUP_TIMEOUT = 60; + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final int MINIMAL_SECONDS_FOR_RENEWAL = 3; + public static final int MAX_WAIT_SECONDS = 30; + + private static final String OPERATOR_1_POD_NAME = "leader-election-operator-1"; + private static final String OPERATOR_2_POD_NAME = "leader-election-operator-2"; + public static final int MINIMAL_EXPECTED_RECONCILIATION = 3; + + private String namespace; + private KubernetesClient client; + + @ParameterizedTest + @ValueSource(strings = {"namespace-inferred-", ""}) + // not for local mode by design + @EnabledIfSystemProperty(named = "test.deployment", matches = "remote") + void otherInstancesTakesOverWhenSteppingDown(String yamlFilePrefix) { + log.info("Applying custom resource"); + applyCustomResource(); + log.info("Deploying operator instances"); + deployOperatorsInOrder(yamlFilePrefix); + + log.info("Awaiting custom resource reconciliations"); + await() + .pollDelay(Duration.ofSeconds(MINIMAL_SECONDS_FOR_RENEWAL)) + .atMost(Duration.ofSeconds(MAX_WAIT_SECONDS)) + .untilAsserted( + () -> { + var actualStatus = + client + .resources(LeaderElection.class) + .inNamespace(namespace) + .withName(TEST_RESOURCE_NAME) + .get() + .getStatus(); + + assertThat(actualStatus).isNotNull(); + assertThat(actualStatus.getReconciledBy()) + .hasSizeGreaterThan(MINIMAL_EXPECTED_RECONCILIATION); + }); + + client.pods().inNamespace(namespace).withName(OPERATOR_1_POD_NAME).delete(); + + var actualListSize = + client + .resources(LeaderElection.class) + .inNamespace(namespace) + .withName(TEST_RESOURCE_NAME) + .get() + .getStatus() + .getReconciledBy() + .size(); + + await() + .pollDelay(Duration.ofSeconds(MINIMAL_SECONDS_FOR_RENEWAL)) + .atMost(Duration.ofSeconds(240)) + .untilAsserted( + () -> { + var actualStatus = + client + .resources(LeaderElection.class) + .inNamespace(namespace) + .withName(TEST_RESOURCE_NAME) + .get() + .getStatus(); + + assertThat(actualStatus).isNotNull(); + assertThat(actualStatus.getReconciledBy()) + .hasSizeGreaterThan(actualListSize + MINIMAL_EXPECTED_RECONCILIATION); + }); + + assertReconciliations( + client + .resources(LeaderElection.class) + .inNamespace(namespace) + .withName(TEST_RESOURCE_NAME) + .get() + .getStatus() + .getReconciledBy()); + } + + private void assertReconciliations(List reconciledBy) { + log.info("Reconciled by content: {}", reconciledBy); + OptionalInt firstO2StatusIndex = + IntStream.range(0, reconciledBy.size()) + .filter(i -> reconciledBy.get(i).equals(OPERATOR_2_POD_NAME)) + .findFirst(); + assertThat(firstO2StatusIndex).isPresent(); + assertThat(reconciledBy.subList(0, firstO2StatusIndex.getAsInt() - 1)) + .allMatch(s -> s.equals(OPERATOR_1_POD_NAME)); + assertThat(reconciledBy.subList(firstO2StatusIndex.getAsInt(), reconciledBy.size())) + .allMatch(s -> s.equals(OPERATOR_2_POD_NAME)); + } + + private void applyCustomResource() { + var res = new LeaderElection(); + res.setMetadata( + new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).withNamespace(namespace).build()); + client.resource(res).create(); + } + + @BeforeEach + void setup() { + namespace = "leader-election-it-" + UUID.randomUUID(); + client = + new KubernetesClientBuilder() + .withConfig(new ConfigBuilder().withNamespace(namespace).build()) + .build(); + applyCRD(); + client + .namespaces() + .resource( + new NamespaceBuilder().withNewMetadata().withName(namespace).endMetadata().build()) + .create(); + } + + @AfterEach + void tearDown() { + client + .namespaces() + .resource( + new NamespaceBuilder().withNewMetadata().withName(namespace).endMetadata().build()) + .delete(); + await() + .atMost(Duration.ofSeconds(60)) + .untilAsserted(() -> assertThat(client.namespaces().withName(namespace).get()).isNull()); + } + + private void deployOperatorsInOrder(String yamlFilePrefix) { + log.info("Installing 1st instance"); + applyResources("k8s/" + yamlFilePrefix + "operator.yaml"); + await() + .atMost(Duration.ofSeconds(POD_STARTUP_TIMEOUT)) + .untilAsserted( + () -> { + var pod = client.pods().inNamespace(namespace).withName(OPERATOR_1_POD_NAME).get(); + assertThat(pod.getStatus().getContainerStatuses()).isNotEmpty(); + assertThat(pod.getStatus().getContainerStatuses().get(0).getReady()).isTrue(); + }); + + log.info("Installing 2nd instance"); + applyResources("k8s/" + yamlFilePrefix + "operator-instance-2.yaml"); + await() + .atMost(Duration.ofSeconds(POD_STARTUP_TIMEOUT)) + .untilAsserted( + () -> { + var pod = client.pods().inNamespace(namespace).withName(OPERATOR_2_POD_NAME).get(); + assertThat(pod.getStatus().getContainerStatuses()).isNotEmpty(); + assertThat(pod.getStatus().getContainerStatuses().get(0).getReady()).isTrue(); + }); + } + + void applyCRD() { + String path = "./src/main/resources/kubernetes/leaderelections.sample.javaoperatorsdk-v1.yml"; + try (InputStream is = new FileInputStream(path)) { + final var crd = client.load(is); + crd.createOrReplace(); + Thread.sleep(CRD_READY_WAIT); + log.debug("Applied CRD with name: {}", crd.get().get(0).getMetadata().getName()); + } catch (InterruptedException | IOException e) { + throw new RuntimeException(e); + } + } + + void applyResources(String path) { + try { + List resources = client.load(new FileInputStream(path)).items(); + resources.forEach( + hm -> { + hm.getMetadata().setNamespace(namespace); + if (hm.getKind().toLowerCase(Locale.ROOT).equals("clusterrolebinding")) { + var crb = (ClusterRoleBinding) hm; + for (var subject : crb.getSubjects()) { + subject.setNamespace(namespace); + } + } + }); + client.resourceList(resources).inNamespace(namespace).createOrReplace(); + + } catch (FileNotFoundException e) { + throw new RuntimeException(e); + } + } +} diff --git a/sample-operators/leader-election/src/test/resources/log4j2.xml b/sample-operators/leader-election/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..2b7fdd3479 --- /dev/null +++ b/sample-operators/leader-election/src/test/resources/log4j2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample-operators/mysql-schema/README.md b/sample-operators/mysql-schema/README.md new file mode 100644 index 0000000000..a0f7999090 --- /dev/null +++ b/sample-operators/mysql-schema/README.md @@ -0,0 +1,103 @@ +# MySQL Schema Operator + +This example shows how an operator can control resources outside of the Kubernetes cluster. In this case it will be +managing MySQL schemas in an existing database server. This is a common scenario in many organizations where developers +need to create schemas for different applications and environments, but the database server itself is managed by a +different team. Using this operator a dev team can create a CR in their namespace and have a schema provisioned automatically. +Access to the MySQL server is configured in the configuration of the operator, so admin access is restricted. + +This is an example input: +```yaml +apiVersion: "mysql.sample.javaoperatorsdk/v1" +kind: MySQLSchema +metadata: + name: mydb +spec: + encoding: utf8 +``` + +Creating this custom resource will prompt the operator to create a schema named `mydb` in the MySQL server and update +the resource status with its URL. Once the resource is deleted, the operator will delete the schema. Obviously don't +use it as is with real databases. + +### Try + +To try how the operator works you will need the following: +* JDK installed (minimum version 11, tested with 11, 15, and 23) +* Maven installed (tested with 3.6.3) +* A working Kubernetes cluster (tested with v1.15.9-gke.24 and minikube v1.35.0) +* kubectl installed (tested with v1.15.5) +* Docker installed (tested with 19.03.8) +* Container image registry + +How to configure all the above depends heavily on where your Kubernetes cluster is hosted. +If you use [minikube](https://minikube.sigs.k8s.io/docs/) you will need to configure kubectl and docker differently +than if you'd use [GKE](https://cloud.google.com/kubernetes-engine/). You will have to read the documentation of your +Kubernetes provider to figure this out. + +Once you have the basics you can build and deploy the operator. + +### Build & Deploy + +1. We will be building the Docker image from the source code using Maven, so we have to configure the Docker registry +where the image should be pushed. Do this in mysql-schema/pom.xml. In the example below I'm setting it to +the [Container Registry](https://cloud.google.com/container-registry/) in Google Cloud Europe. + +```xml + + eu.gcr.io/my-gcp-project/mysql-operator + +``` + +1. The following Maven command will build the JAR file, package it as a Docker image and push it to the registry. + + `mvn jib:dockerBuild` + +1. Deploy the test MySQL on your cluster if you want to use it. Note that if you have an already running MySQL server +you want to use, you can skip this step, but you will have to configure the operator to use that server. + + `kubectl apply -f k8s/mysql-db.yaml` +1. Deploy the CRD: + + `kubectl apply -f target/classes/META-INF/fabric8/mysqlschemas.mysql.sample.javaoperatorsdk-v1.yml` + +1. Make a copy of `k8s/operator.yaml` and replace `spec.template.spec.containers[0].image` (`$ yq 'select(di == 1).spec.template.spec.containers[0].image' k8s/operator.yaml`) with the operator image that you pushed to your registry. This should be the same as you set the docker-registry + property in your `pom.xml`. +If you look at the environment variables you will notice this is where the access to the MySQL server is configured. +The default values assume the server is running in another Kubernetes namespace (called `mysql`), uses the `root` user +with a not very secure password. In case you want to use a different MySQL server, this is where you configure it. + +1. Run `kubectl apply -f copy-of-operator.yaml` to deploy the operator. You can wait for the deployment to succeed using +this command: `kubectl rollout status deployment -n mysql-schema-operator mysql-schema-operator -w`. `-w` will cause kubectl to continuously monitor the deployment until you stop it. + +1. Now you are ready to create some databases! To create a database schema called `mydb` just apply the `k8s/schema.yaml` +file with kubectl: `kubectl apply -f k8s/schema.yaml`. You can modify the database name in the file to create more schemas. To verify, that the schema is installed you need to expose your `LoadBalancer` `mysql` service and you can use the `mysql` + CLI to run `show schemas;` command. For instance, with minikube, this can be done like this: + +``` +$ minikube service mysql -n mysql --url +http://192.168.49.2:30317 + +$ mysql -h 192.168.49.2 -P 30317 --protocol=tcp -u root -ppassword +... + +MariaDB [(none)]> show schemas; ++--------------------+ +| Database | ++--------------------+ +| information_schema | +| mydb | +| mysql | +| performance_schema | +| sys | ++--------------------+ +5 rows in set (0.000 sec) +``` + +Or you can verify it directly with `kubectl` like this: + +``` +$ kubectl get mysqlschemas +NAME AGE +mydb 102s +``` diff --git a/samples/mysql-schema/k8s/mysql.yaml b/sample-operators/mysql-schema/k8s/mysql-db.yaml similarity index 57% rename from samples/mysql-schema/k8s/mysql.yaml rename to sample-operators/mysql-schema/k8s/mysql-db.yaml index 236ec47884..d80238b32e 100644 --- a/samples/mysql-schema/k8s/mysql.yaml +++ b/sample-operators/mysql-schema/k8s/mysql-db.yaml @@ -2,18 +2,8 @@ apiVersion: v1 kind: Namespace metadata: name: mysql ---- -apiVersion: v1 -kind: Service -metadata: - name: mysql - namespace: mysql -spec: - ports: - - port: 3306 - selector: - app: mysql - type: NodePort + labels: + name: mysql --- apiVersion: apps/v1 kind: Deployment @@ -32,12 +22,24 @@ spec: app: mysql spec: containers: - - image: mysql:5.6 - name: mysql - env: - # Use secret in real usage - - name: MYSQL_ROOT_PASSWORD - value: password - ports: - - containerPort: 3306 - name: mysql \ No newline at end of file + - image: mariadb:10.7 + name: mysql + env: + # Use secret in real usage + - name: MYSQL_ROOT_PASSWORD + value: password + ports: + - containerPort: 3306 + name: mysql +--- +apiVersion: v1 +kind: Service +metadata: + name: mysql + namespace: mysql +spec: + ports: + - port: 3306 + selector: + app: mysql + type: LoadBalancer \ No newline at end of file diff --git a/sample-operators/mysql-schema/k8s/operator.yaml b/sample-operators/mysql-schema/k8s/operator.yaml new file mode 100644 index 0000000000..48ddea6a35 --- /dev/null +++ b/sample-operators/mysql-schema/k8s/operator.yaml @@ -0,0 +1,101 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mysql-schema-operator +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mysql-schema-operator + namespace: mysql-schema-operator +spec: + selector: + matchLabels: + app: mysql-schema-operator + replicas: 1 # we always run a single replica of the operator to avoid duplicate handling of events + strategy: + type: Recreate # during an upgrade the operator will shut down before the new version comes up to prevent two instances running at the same time + template: + metadata: + labels: + app: mysql-schema-operator + 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 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + env: + - name: MYSQL_HOST + value: mysql.mysql # assuming the MySQL server runs in a namespace called "mysql" on Kubernetes + - name: MYSQL_USER + value: root + - name: MYSQL_PASSWORD + value: password # sample-level security + readinessProbe: + httpGet: + path: /health # when this returns 200 the operator is considered up and running + port: 8080 + initialDelaySeconds: 1 + timeoutSeconds: 1 + livenessProbe: + httpGet: + path: /health # when this endpoint doesn't return 200 the operator is considered broken and get's restarted + port: 8080 + initialDelaySeconds: 30 + timeoutSeconds: 1 + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mysql-schema-operator + namespace: mysql-schema-operator + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mysql-schema-operator +rules: +- apiGroups: + - mysql.sample.javaoperatorsdk + resources: + - mysqlschemas + verbs: + - "*" +- apiGroups: + - mysql.sample.javaoperatorsdk + resources: + - mysqlschemas/status + verbs: + - "*" +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - "get" + - "list" +- apiGroups: + - "" + resources: + - secrets + verbs: + - "*" + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: mysql-schema-operator +subjects: +- kind: ServiceAccount + name: mysql-schema-operator + namespace: mysql-schema-operator +roleRef: + kind: ClusterRole + name: mysql-schema-operator + apiGroup: "" diff --git a/samples/mysql-schema/crd/example.yaml b/sample-operators/mysql-schema/k8s/schema.yaml similarity index 100% rename from samples/mysql-schema/crd/example.yaml rename to sample-operators/mysql-schema/k8s/schema.yaml diff --git a/sample-operators/mysql-schema/pom.xml b/sample-operators/mysql-schema/pom.xml new file mode 100644 index 0000000000..8201c1148e --- /dev/null +++ b/sample-operators/mysql-schema/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + + io.javaoperatorsdk + sample-operators + 5.1.5-SNAPSHOT + + + sample-mysql-schema-operator + jar + Operator SDK - Samples - MySQL Schema + Provisions Schemas in a MySQL database + + + + + io.javaoperatorsdk + operator-framework-bom + ${project.version} + pom + import + + + + + + + io.javaoperatorsdk + operator-framework + + + io.javaoperatorsdk + micrometer-support + + + org.takes + takes + 1.24.6 + + + mysql + mysql-connector-java + 8.0.33 + + + org.apache.logging.log4j + log4j-slf4j2-impl + + + org.apache.logging.log4j + log4j-core + compile + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.awaitility + awaitility + test + + + io.javaoperatorsdk + operator-framework-junit-5 + test + + + + + + + io.fabric8 + crd-generator-maven-plugin + ${fabric8-client.version} + + + + generate + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + 0 + + + + com.google.cloud.tools + jib-maven-plugin + ${jib-maven-plugin.version} + + + gcr.io/distroless/java17-debian11 + + + mysql-schema-operator + + + + + + + diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLDbConfig.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLDbConfig.java new file mode 100644 index 0000000000..6f409720fe --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLDbConfig.java @@ -0,0 +1,48 @@ +package io.javaoperatorsdk.operator.sample; + +import org.apache.commons.lang3.ObjectUtils; + +public class MySQLDbConfig { + + private final String host; + private final String port; + private final String user; + private final String password; + + public MySQLDbConfig(String host, String port, String user, String password) { + this.host = host; + this.port = port != null ? port : "3306"; + this.user = user; + this.password = password; + } + + public static MySQLDbConfig loadFromEnvironmentVars() { + if (ObjectUtils.anyNull( + System.getenv("MYSQL_HOST"), + System.getenv("MYSQL_USER"), + System.getenv("MYSQL_PASSWORD"))) { + throw new IllegalStateException("Mysql server parameters not defined"); + } + return new MySQLDbConfig( + System.getenv("MYSQL_HOST"), + System.getenv("MYSQL_PORT"), + System.getenv("MYSQL_USER"), + System.getenv("MYSQL_PASSWORD")); + } + + public String getHost() { + return host; + } + + public String getPort() { + return port; + } + + public String getUser() { + return user; + } + + public String getPassword() { + return password; + } +} diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchema.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchema.java new file mode 100644 index 0000000000..adc6335c43 --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchema.java @@ -0,0 +1,10 @@ +package io.javaoperatorsdk.operator.sample; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("mysql.sample.javaoperatorsdk") +@Version("v1") +public class MySQLSchema extends CustomResource implements Namespaced {} diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java new file mode 100644 index 0000000000..c155f0ac6b --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java @@ -0,0 +1,46 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.IOException; +import java.time.Duration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.takes.facets.fork.FkRegex; +import org.takes.facets.fork.TkFork; +import org.takes.http.Exit; +import org.takes.http.FtBasic; + +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetrics; +import io.javaoperatorsdk.operator.sample.dependent.ResourcePollerConfig; +import io.javaoperatorsdk.operator.sample.dependent.SchemaDependentResource; +import io.micrometer.core.instrument.logging.LoggingMeterRegistry; + +public class MySQLSchemaOperator { + + private static final Logger log = LoggerFactory.getLogger(MySQLSchemaOperator.class); + + public static void main(String[] args) throws IOException { + log.info("MySQL Schema Operator starting"); + + Operator operator = + new Operator( + overrider -> + overrider.withMetrics( + MicrometerMetrics.withoutPerResourceMetrics(new LoggingMeterRegistry()))); + + MySQLSchemaReconciler schemaReconciler = new MySQLSchemaReconciler(); + + // override the default configuration + operator.register( + schemaReconciler, + configOverrider -> + configOverrider.replacingNamedDependentResourceConfig( + SchemaDependentResource.NAME, + new ResourcePollerConfig( + Duration.ofMillis(300), MySQLDbConfig.loadFromEnvironmentVars()))); + operator.start(); + + new FtBasic(new TkFork(new FkRegex("/health", "ALL GOOD!")), 8080).start(Exit.NEVER); + } +} diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaReconciler.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaReconciler.java new file mode 100644 index 0000000000..38e94f4d8f --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaReconciler.java @@ -0,0 +1,81 @@ +package io.javaoperatorsdk.operator.sample; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.sample.dependent.SchemaDependentResource; +import io.javaoperatorsdk.operator.sample.dependent.SecretDependentResource; +import io.javaoperatorsdk.operator.sample.schema.Schema; + +import static io.javaoperatorsdk.operator.sample.dependent.SchemaDependentResource.decode; +import static io.javaoperatorsdk.operator.sample.dependent.SecretDependentResource.MYSQL_SECRET_USERNAME; +import static java.lang.String.format; + +@Workflow( + dependents = { + @Dependent(type = SecretDependentResource.class, name = SecretDependentResource.NAME), + @Dependent( + type = SchemaDependentResource.class, + name = SchemaDependentResource.NAME, + dependsOn = SecretDependentResource.NAME) + }) +@ControllerConfiguration +public class MySQLSchemaReconciler implements Reconciler { + + static final Logger log = LoggerFactory.getLogger(MySQLSchemaReconciler.class); + + @Override + public UpdateControl reconcile(MySQLSchema schema, Context context) { + // we only need to update the status if we just built the schema, i.e. when it's present in the + // context + Secret secret = context.getSecondaryResource(Secret.class).orElseThrow(); + + return context + .getSecondaryResource(Schema.class, SchemaDependentResource.NAME) + .map( + s -> { + var statusUpdateResource = + createForStatusUpdate( + schema, + s, + secret.getMetadata().getName(), + decode(secret.getData().get(MYSQL_SECRET_USERNAME))); + log.info("Schema {} created - updating CR status", s.getName()); + return UpdateControl.patchStatus(statusUpdateResource); + }) + .orElseGet(UpdateControl::noUpdate); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + MySQLSchema schema, Context context, Exception e) { + SchemaStatus status = new SchemaStatus(); + status.setUrl(null); + status.setUserName(null); + status.setSecretName(null); + status.setStatus("ERROR: " + e.getMessage()); + schema.setStatus(status); + return ErrorStatusUpdateControl.patchStatus(schema); + } + + private MySQLSchema createForStatusUpdate( + MySQLSchema mySQLSchema, Schema schema, String secretName, String userName) { + MySQLSchema res = new MySQLSchema(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(mySQLSchema.getMetadata().getName()) + .withNamespace(mySQLSchema.getMetadata().getNamespace()) + .build()); + SchemaStatus status = new SchemaStatus(); + status.setUrl(format("jdbc:mysql://%1$s/%2$s", System.getenv("MYSQL_HOST"), schema.getName())); + status.setUserName(userName); + status.setSecretName(secretName); + status.setStatus("CREATED"); + res.setStatus(status); + return res; + } +} diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/SchemaSpec.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/SchemaSpec.java new file mode 100644 index 0000000000..19101c328a --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/SchemaSpec.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.sample; + +public class SchemaSpec { + + private String encoding; + + public String getEncoding() { + return encoding; + } + + public void setEncoding(String encoding) { + this.encoding = encoding; + } +} diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/SchemaStatus.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/SchemaStatus.java new file mode 100644 index 0000000000..168cd8db15 --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/SchemaStatus.java @@ -0,0 +1,44 @@ +package io.javaoperatorsdk.operator.sample; + +public class SchemaStatus { + + private String url; + + private String status; + + private String userName; + + private String secretName; + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getSecretName() { + return secretName; + } + + public void setSecretName(String secretName) { + this.secretName = secretName; + } +} diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/ResourcePollerConfig.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/ResourcePollerConfig.java new file mode 100644 index 0000000000..feaf326a8d --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/ResourcePollerConfig.java @@ -0,0 +1,24 @@ +package io.javaoperatorsdk.operator.sample.dependent; + +import java.time.Duration; + +import io.javaoperatorsdk.operator.sample.MySQLDbConfig; + +public class ResourcePollerConfig { + + private final Duration pollPeriod; + private final MySQLDbConfig mySQLDbConfig; + + public ResourcePollerConfig(Duration pollPeriod, MySQLDbConfig mySQLDbConfig) { + this.pollPeriod = pollPeriod; + this.mySQLDbConfig = mySQLDbConfig; + } + + public Duration getPollPeriod() { + return pollPeriod; + } + + public MySQLDbConfig getMySQLDbConfig() { + return mySQLDbConfig; + } +} diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SchemaConfig.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SchemaConfig.java new file mode 100644 index 0000000000..c43a2a060f --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SchemaConfig.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.operator.sample.dependent; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface SchemaConfig { + int DEFAULT_POLL_PERIOD = 500; + int DEFAULT_PORT = 3306; + + int pollPeriod() default DEFAULT_POLL_PERIOD; + + String host(); + + String user(); + + String password(); + + int port() default DEFAULT_PORT; +} diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SchemaDependentResource.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SchemaDependentResource.java new file mode 100644 index 0000000000..ec2b03325c --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SchemaDependentResource.java @@ -0,0 +1,147 @@ +package io.javaoperatorsdk.operator.sample.dependent; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.time.Duration; +import java.util.Base64; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.dependent.ConfigurationConverter; +import io.javaoperatorsdk.operator.api.config.dependent.Configured; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Deleter; +import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ConfiguredDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.external.PerResourcePollingDependentResource; +import io.javaoperatorsdk.operator.sample.MySQLDbConfig; +import io.javaoperatorsdk.operator.sample.MySQLSchema; +import io.javaoperatorsdk.operator.sample.dependent.SchemaDependentResource.ResourcePollerConfigConverter; +import io.javaoperatorsdk.operator.sample.schema.Schema; +import io.javaoperatorsdk.operator.sample.schema.SchemaService; + +import static io.javaoperatorsdk.operator.sample.dependent.SecretDependentResource.MYSQL_SECRET_PASSWORD; +import static io.javaoperatorsdk.operator.sample.dependent.SecretDependentResource.MYSQL_SECRET_USERNAME; +import static java.lang.String.format; + +@SchemaConfig( + pollPeriod = 400, + host = "127.0.0.1", + port = SchemaDependentResource.LOCAL_PORT, + user = "root", + password = "password") // NOSONAR: password is only used locally, example only +@Configured( + by = SchemaConfig.class, + with = ResourcePollerConfig.class, + converter = ResourcePollerConfigConverter.class) +public class SchemaDependentResource + extends PerResourcePollingDependentResource + implements ConfiguredDependentResource, + Creator, + Deleter { + + public static final String NAME = "schema"; + public static final int LOCAL_PORT = 3307; + private static final Logger log = LoggerFactory.getLogger(SchemaDependentResource.class); + + private MySQLDbConfig dbConfig; + + @Override + public Optional configuration() { + return Optional.of(new ResourcePollerConfig(getPollingPeriod(), dbConfig)); + } + + @Override + public void configureWith(ResourcePollerConfig config) { + this.dbConfig = config.getMySQLDbConfig(); + setPollingPeriod(config.getPollPeriod()); + } + + @Override + public Schema desired(MySQLSchema primary, Context context) { + var desired = new Schema(primary.getMetadata().getName(), primary.getSpec().getEncoding()); + log.debug("Desired schema: {}", desired); + return desired; + } + + @Override + public Schema create(Schema target, MySQLSchema mySQLSchema, Context context) { + try (Connection connection = getConnection()) { + Secret secret = context.getSecondaryResource(Secret.class).orElseThrow(); + var username = decode(secret.getData().get(MYSQL_SECRET_USERNAME)); + var password = decode(secret.getData().get(MYSQL_SECRET_PASSWORD)); + log.debug("Creating schema: {}", target); + return SchemaService.createSchemaAndRelatedUser( + connection, target.getName(), target.getCharacterSet(), username, password); + } catch (SQLException e) { + log.error("Error while creating Schema", e); + throw new IllegalStateException(e); + } + } + + private Connection getConnection() throws SQLException { + String connectURL = format("jdbc:mysql://%1$s:%2$s", dbConfig.getHost(), dbConfig.getPort()); + log.debug("Connecting to '{}' with user '{}'", connectURL, dbConfig.getUser()); + return DriverManager.getConnection(connectURL, dbConfig.getUser(), dbConfig.getPassword()); + } + + @Override + public void delete(MySQLSchema primary, Context context) { + try (Connection connection = getConnection()) { + var userName = primary.getStatus() != null ? primary.getStatus().getUserName() : null; + SchemaService.deleteSchemaAndRelatedUser( + connection, primary.getMetadata().getName(), userName); + } catch (SQLException e) { + throw new RuntimeException("Error while trying to delete Schema", e); + } + } + + public static String decode(String value) { + return new String(Base64.getDecoder().decode(value.getBytes())); + } + + @Override + public Set fetchResources(MySQLSchema primaryResource) { + try (Connection connection = getConnection()) { + var schema = + SchemaService.getSchema(connection, primaryResource.getMetadata().getName()) + .map(Set::of) + .orElseGet(Collections::emptySet); + log.debug("Fetched schema: {}", schema); + return schema; + } catch (SQLException e) { + throw new RuntimeException("Error while trying read Schema", e); + } + } + + static class ResourcePollerConfigConverter + implements ConfigurationConverter { + + @Override + public ResourcePollerConfig configFrom( + SchemaConfig configAnnotation, + DependentResourceSpec spec, + ControllerConfiguration parentConfiguration) { + if (configAnnotation != null) { + return new ResourcePollerConfig( + Duration.ofMillis(configAnnotation.pollPeriod()), + new MySQLDbConfig( + configAnnotation.host(), + String.valueOf(configAnnotation.port()), + configAnnotation.user(), + configAnnotation.password())); + } + return new ResourcePollerConfig( + Duration.ofMillis(SchemaConfig.DEFAULT_POLL_PERIOD), + MySQLDbConfig.loadFromEnvironmentVars()); + } + } +} diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SecretDependentResource.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SecretDependentResource.java new file mode 100644 index 0000000000..cff28feadd --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/dependent/SecretDependentResource.java @@ -0,0 +1,71 @@ +package io.javaoperatorsdk.operator.sample.dependent; + +import java.util.Base64; +import java.util.Set; + +import org.apache.commons.lang3.RandomStringUtils; + +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.Creator; +import io.javaoperatorsdk.operator.processing.dependent.Matcher.Result; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.sample.MySQLSchema; + +@KubernetesDependent +public class SecretDependentResource extends KubernetesDependentResource + implements Creator, SecondaryToPrimaryMapper { + + public static final String NAME = "secret"; + public static final String SECRET_SUFFIX = "-secret"; + public static final String SECRET_FORMAT = "%s" + SECRET_SUFFIX; + public static final String USERNAME_FORMAT = "%s-user"; + public static final String MYSQL_SECRET_USERNAME = "mysql.secret.user.name"; + public static final String MYSQL_SECRET_PASSWORD = "mysql.secret.user.password"; + + private static String encode(String value) { + return Base64.getEncoder().encodeToString(value.getBytes()); + } + + @Override + protected Secret desired(MySQLSchema schema, Context context) { + final var password = + RandomStringUtils.randomAlphanumeric( + 16); // NOSONAR: we don't need cryptographically-strong randomness here + final var name = schema.getMetadata().getName(); + final var secretName = getSecretName(name); + final var userName = String.format(USERNAME_FORMAT, name); + + return new SecretBuilder() + .withNewMetadata() + .withName(secretName) + .withNamespace(schema.getMetadata().getNamespace()) + .endMetadata() + .addToData(MYSQL_SECRET_USERNAME, encode(userName)) + .addToData(MYSQL_SECRET_PASSWORD, encode(password)) + .build(); + } + + private String getSecretName(String schemaName) { + return String.format(SECRET_FORMAT, schemaName); + } + + @Override + public Result match(Secret actual, MySQLSchema primary, Context context) { + final var desiredSecretName = getSecretName(primary.getMetadata().getName()); + return Result.nonComputed(actual.getMetadata().getName().equals(desiredSecretName)); + } + + @Override + public Set toPrimaryResourceIDs(Secret resource) { + String name = resource.getMetadata().getName(); + return Set.of( + new ResourceID( + name.substring(0, name.length() - SECRET_SUFFIX.length()), + resource.getMetadata().getNamespace())); + } +} diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/schema/Schema.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/schema/Schema.java new file mode 100644 index 0000000000..3ec6d8f008 --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/schema/Schema.java @@ -0,0 +1,41 @@ +package io.javaoperatorsdk.operator.sample.schema; + +import java.io.Serializable; +import java.util.Objects; + +public class Schema implements Serializable { + + private final String name; + private final String characterSet; + + public Schema(String name, String characterSet) { + this.name = name; + this.characterSet = characterSet; + } + + public String getName() { + return name; + } + + public String getCharacterSet() { + return characterSet; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Schema schema = (Schema) o; + return Objects.equals(name, schema.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public String toString() { + return "Schema{" + "name='" + name + '\'' + ", characterSet='" + characterSet + '\'' + '}'; + } +} diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/schema/SchemaService.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/schema/SchemaService.java new file mode 100644 index 0000000000..3690219902 --- /dev/null +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/schema/SchemaService.java @@ -0,0 +1,125 @@ +package io.javaoperatorsdk.operator.sample.schema; + +import java.sql.*; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.sample.MySQLDbConfig; + +import static java.lang.String.format; + +public class SchemaService { + + private static final Logger log = LoggerFactory.getLogger(SchemaService.class); + + private final MySQLDbConfig mySQLDbConfig; + + public SchemaService(MySQLDbConfig mySQLDbConfig) { + this.mySQLDbConfig = mySQLDbConfig; + } + + public Optional getSchema(String name) { + try (Connection connection = getConnection()) { + return getSchema(connection, name); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + + public static Schema createSchemaAndRelatedUser( + Connection connection, String schemaName, String encoding, String userName, String password) { + try { + try (Statement statement = connection.createStatement()) { + statement.execute( + format("CREATE SCHEMA `%1$s` DEFAULT CHARACTER SET %2$s", schemaName, encoding)); + } + if (!userExists(connection, userName)) { + try (Statement statement = connection.createStatement()) { + statement.execute(format("CREATE USER '%1$s' IDENTIFIED BY '%2$s'", userName, password)); + } + } + try (Statement statement = connection.createStatement()) { + statement.execute(format("GRANT ALL ON `%1$s`.* TO '%2$s'", schemaName, userName)); + } + + return new Schema(schemaName, encoding); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + + public static void deleteSchemaAndRelatedUser( + Connection connection, String schemaName, String userName) { + try { + if (schemaExists(connection, schemaName)) { + try (Statement statement = connection.createStatement()) { + statement.execute(format("DROP DATABASE `%1$s`", schemaName)); + } + log.info("Deleted Schema '{}'", schemaName); + } + + if (userName != null && userExists(connection, userName)) { + try (Statement statement = connection.createStatement()) { + statement.execute(format("DROP USER '%1$s'", userName)); + } + log.info("Deleted User '{}'", userName); + } + + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + + private static boolean userExists(Connection connection, String username) { + try (PreparedStatement ps = + connection.prepareStatement("SELECT 1 FROM mysql.user WHERE user = ?")) { + ps.setString(1, username); + try (ResultSet resultSet = ps.executeQuery()) { + return resultSet.next(); + } + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + + public static boolean schemaExists(Connection connection, String schemaName) { + return getSchema(connection, schemaName).isPresent(); + } + + public static Optional getSchema(Connection connection, String schemaName) { + try (PreparedStatement ps = + connection.prepareStatement( + "SELECT * FROM information_schema.schemata WHERE schema_name = ?")) { + ps.setString(1, schemaName); + try (ResultSet resultSet = ps.executeQuery()) { + // CATALOG_NAME, SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME, SQL_PATH + var exists = resultSet.next(); + if (!exists) { + return Optional.empty(); + } else { + return Optional.of( + new Schema( + resultSet.getString("SCHEMA_NAME"), + resultSet.getString("DEFAULT_CHARACTER_SET_NAME"))); + } + } + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } + + private Connection getConnection() { + try { + String connectionString = + format("jdbc:mysql://%1$s:%2$s", mySQLDbConfig.getHost(), mySQLDbConfig.getPort()); + + log.debug("Connecting to '{}' with user '{}'", connectionString, mySQLDbConfig.getUser()); + return DriverManager.getConnection( + connectionString, mySQLDbConfig.getUser(), mySQLDbConfig.getPassword()); + } catch (SQLException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/samples/mysql-schema/src/main/resources/log4j2.xml b/sample-operators/mysql-schema/src/main/resources/log4j2.xml similarity index 92% rename from samples/mysql-schema/src/main/resources/log4j2.xml rename to sample-operators/mysql-schema/src/main/resources/log4j2.xml index 5ab4735126..01484221f9 100644 --- a/samples/mysql-schema/src/main/resources/log4j2.xml +++ b/sample-operators/mysql-schema/src/main/resources/log4j2.xml @@ -6,7 +6,7 @@ - + diff --git a/sample-operators/mysql-schema/src/test/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperatorE2E.java b/sample-operators/mysql-schema/src/test/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperatorE2E.java new file mode 100644 index 0000000000..92339d0e2c --- /dev/null +++ b/sample-operators/mysql-schema/src/test/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperatorE2E.java @@ -0,0 +1,128 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.NamespaceBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.javaoperatorsdk.operator.junit.AbstractOperatorExtension; +import io.javaoperatorsdk.operator.junit.ClusterDeployedOperatorExtension; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.sample.dependent.SchemaDependentResource; + +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +class MySQLSchemaOperatorE2E { + + static final Logger log = LoggerFactory.getLogger(MySQLSchemaOperatorE2E.class); + + static final KubernetesClient client = new KubernetesClientBuilder().build(); + + static final String MY_SQL_NS = "mysql"; + + private static final List infrastructure = new ArrayList<>(); + public static final String TEST_RESOURCE_NAME = "mydb1"; + + static { + infrastructure.add( + new NamespaceBuilder().withNewMetadata().withName(MY_SQL_NS).endMetadata().build()); + try { + infrastructure.addAll(client.load(new FileInputStream("k8s/mysql-db.yaml")).items()); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + } + + boolean isLocal() { + String deployment = System.getProperty("test.deployment"); + boolean remote = (deployment != null && deployment.equals("remote")); + log.info("Running the operator " + (remote ? "remotely" : "locally")); + return !remote; + } + + @RegisterExtension + AbstractOperatorExtension operator = + isLocal() + ? LocallyRunOperatorExtension.builder() + .withReconciler(new MySQLSchemaReconciler()) // configuration for schema comes from + // SchemaDependentResource annotation + .withInfrastructure(infrastructure) + .withPortForward(MY_SQL_NS, "app", "mysql", 3306, SchemaDependentResource.LOCAL_PORT) + .build() + : ClusterDeployedOperatorExtension.builder() + .withOperatorDeployment(client.load(new FileInputStream("k8s/operator.yaml")).items()) + .withInfrastructure(infrastructure) + .build(); + + public MySQLSchemaOperatorE2E() throws FileNotFoundException {} + + @Test + void test() { + + MySQLSchema testSchema = new MySQLSchema(); + testSchema.setMetadata( + new ObjectMetaBuilder() + .withName(TEST_RESOURCE_NAME) + .withNamespace(operator.getNamespace()) + .build()); + testSchema.setSpec(new SchemaSpec()); + testSchema.getSpec().setEncoding("utf8"); + + log.info("Creating test MySQLSchema object: {}", testSchema); + client.resource(testSchema).createOrReplace(); + + log.info("Waiting 2 minutes for expected resources to be created and updated"); + await() + .atMost(2, MINUTES) + .ignoreExceptions() + .untilAsserted( + () -> { + MySQLSchema updatedSchema = + client + .resources(MySQLSchema.class) + .inNamespace(operator.getNamespace()) + .withName(testSchema.getMetadata().getName()) + .get(); + assertThat(updatedSchema.getStatus(), is(notNullValue())); + assertThat(updatedSchema.getStatus().getStatus(), equalTo("CREATED")); + assertThat(updatedSchema.getStatus().getSecretName(), is(notNullValue())); + assertThat(updatedSchema.getStatus().getUserName(), is(notNullValue())); + }); + + client + .resources(MySQLSchema.class) + .inNamespace(operator.getNamespace()) + .withName(testSchema.getMetadata().getName()) + .delete(); + + await() + .atMost(2, MINUTES) + .ignoreExceptions() + .untilAsserted( + () -> { + MySQLSchema updatedSchema = + client + .resources(MySQLSchema.class) + .inNamespace(operator.getNamespace()) + .withName(testSchema.getMetadata().getName()) + .get(); + assertThat(updatedSchema, is(nullValue())); + }); + } +} diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml new file mode 100644 index 0000000000..c19bb7f3f6 --- /dev/null +++ b/sample-operators/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + + + io.javaoperatorsdk + java-operator-sdk + 5.1.5-SNAPSHOT + + + sample-operators + pom + Operator SDK - Samples + + + tomcat-operator + webpage + mysql-schema + leader-election + controller-namespace-deletion + + diff --git a/sample-operators/tomcat-operator/README.md b/sample-operators/tomcat-operator/README.md new file mode 100644 index 0000000000..82d633d237 --- /dev/null +++ b/sample-operators/tomcat-operator/README.md @@ -0,0 +1,84 @@ +# Tomcat Operator + +Tomcat Operator sample project for the Java Operator SDK. + +## Description +This is a sample project that shows how to use the Java Operator SDK to create an operator that manages +Tomcat webservers and deploy war files in them. The operator will create a Deployment and a Service for each Tomcat +instance. The Tomcat version and the number of replicas can be configured in the Tomcat Custom Resource. The +operator will download and deploy a war file to the target Tomcat instance for each Webapp Custom Resource that is +created. The Webapp resource contains the URL to the WAR file and the context path to deploy the WAR file to. + +This sample demonstrates the following capabilities of the Java Operator SDK: +* Multiple Controllers in a single Operator. The Tomcat resource is managed by the TomcatController while the Webapp +resource is managed by the WebappController. +* Reacting to events about resources created by the controller. The TomcatController will receive events about the +Deployment resources it created. See EventSource section below for more detail. + +## Example input for creating a Tomcat instance +``` +apiVersion: "tomcatoperator.io/v1" +kind: Tomcat +metadata: + name: test-tomcat1 +spec: + version: 9.0 + replicas: 2 +``` + +## Example input for the Webapp +``` +apiVersion: "tomcatoperator.io/v1" +kind: Webapp +metadata: + name: sample-webapp1 +spec: + tomcat: test-tomcat1 + url: http://tomcat.apache.org/tomcat-7.0-doc/appdev/sample/sample.war + contextPath: mysample +``` + +## Getting started / Testing + +The quickest way to try the operator is to run it on your local machine, while it connects to a +local or remote Kubernetes cluster. When you start it, it will use the current kubectl context on +your machine to connect to the cluster. + +Before you run it you have to install the CRDs on your cluster by running: +- `kubectl apply -f target/classes/META-INF/fabric8/tomcats.tomcatoperator.io-v1.yml` +- `kubectl apply -f target/classes/META-INF/fabric8/webapps.tomcatoperator.io-v1.yml` + +The CRDs are generated automatically from your code by simply adding the `crd-generator-apt` +dependency to your `pom.xml` file. + +When the Operator is running you can create some Tomcat Custom Resources. You can find a sample +custom resources in the k8s folder. + +If you want the Operator to be running as a deployment in your cluster, follow the below steps. + +## Build + +You can build the sample using `mvn install jib:dockerBuild` this will produce a Docker image you +can push to the registry of your choice. The JAR file is built using your local Maven and JDK and +then copied into the Docker image. + +## Install Operator into cluster + +Install the CRDs as shown above if you haven't already, then +run `kubectl apply -f k8s/operator.yaml`. Now you can create Tomcat instances with CRs (see examples +above). + +## EventSources +The TomcatController is listening to events about Deployments created by the TomcatOperator by +registering a InformerEventSource with the EventSourceManager. The InformerEventSource will in turn +register a watch on all Deployments managed by the Controller (identified by +the `app.kubernetes.io/managed-by` label). When an event from a Deployment is received we have to +identify which Tomcat object does the Deployment belong to. This is done when the +InformerEventSource creates the event. + +The TomcatController has to take care of setting the `app.kubernetes.io/managed-by` label on the +Deployment so the InformerEventSource can watch the right Deployments. The TomcatController also has +to set `ownerReference` on the Deployment so later the InformerEventSource can identify which Tomcat +does the Deployment belong to. This is necessary so the framework can call the Controller +`reconcile` method correctly. + diff --git a/sample-operators/tomcat-operator/k8s/operator.yaml b/sample-operators/tomcat-operator/k8s/operator.yaml new file mode 100644 index 0000000000..a88b6514e0 --- /dev/null +++ b/sample-operators/tomcat-operator/k8s/operator.yaml @@ -0,0 +1,91 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: tomcat-operator + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: tomcat-operator + namespace: tomcat-operator + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tomcat-operator + namespace: tomcat-operator +spec: + selector: + matchLabels: + app: tomcat-operator + template: + metadata: + labels: + app: tomcat-operator + spec: + serviceAccountName: tomcat-operator + containers: + - name: operator + image: tomcat-operator + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 1 + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 30 + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: tomcat-operator-admin +subjects: +- kind: ServiceAccount + name: tomcat-operator + namespace: tomcat-operator +roleRef: + kind: ClusterRole + name: tomcat-operator + apiGroup: "" + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: tomcat-operator +rules: +- apiGroups: + - "" + - "extensions" + - "apps" + resources: + - deployments + - services + - pods + - pods/exec + verbs: + - '*' +- apiGroups: + - "apiextensions.k8s.io" + resources: + - customresourcedefinitions + verbs: + - '*' +- apiGroups: + - "tomcatoperator.io" + resources: + - tomcats + - tomcats/status + - webapps + - webapps/status + verbs: + - '*' \ No newline at end of file diff --git a/sample-operators/tomcat-operator/k8s/tomcat-sample1.yaml b/sample-operators/tomcat-operator/k8s/tomcat-sample1.yaml new file mode 100644 index 0000000000..ddd30663cd --- /dev/null +++ b/sample-operators/tomcat-operator/k8s/tomcat-sample1.yaml @@ -0,0 +1,7 @@ +apiVersion: "tomcatoperator.io/v1" +kind: Tomcat +metadata: + name: test-tomcat1 +spec: + version: 9.0 + replicas: 2 diff --git a/sample-operators/tomcat-operator/k8s/tomcat-sample2.yaml b/sample-operators/tomcat-operator/k8s/tomcat-sample2.yaml new file mode 100644 index 0000000000..2dec431734 --- /dev/null +++ b/sample-operators/tomcat-operator/k8s/tomcat-sample2.yaml @@ -0,0 +1,7 @@ +apiVersion: "tomcatoperator.io/v1" +kind: Tomcat +metadata: + name: test-tomcat2 +spec: + version: 8.0 + replicas: 4 diff --git a/sample-operators/tomcat-operator/k8s/webapp-sample1.yaml b/sample-operators/tomcat-operator/k8s/webapp-sample1.yaml new file mode 100644 index 0000000000..4913eb2444 --- /dev/null +++ b/sample-operators/tomcat-operator/k8s/webapp-sample1.yaml @@ -0,0 +1,8 @@ +apiVersion: "tomcatoperator.io/v1" +kind: Webapp +metadata: + name: sample-webapp1 +spec: + tomcat: test-tomcat1 + url: http://tomcat.apache.org/tomcat-7.0-doc/appdev/sample/sample.war + contextPath: mysample diff --git a/sample-operators/tomcat-operator/k8s/webapp-sample2.yaml b/sample-operators/tomcat-operator/k8s/webapp-sample2.yaml new file mode 100644 index 0000000000..e0415f9ce5 --- /dev/null +++ b/sample-operators/tomcat-operator/k8s/webapp-sample2.yaml @@ -0,0 +1,8 @@ +apiVersion: "tomcatoperator.io/v1" +kind: Webapp +metadata: + name: sample-webapp2 +spec: + tomcat: test-tomcat2 + url: charlottemach.com/assets/jax.war + contextPath: othercontext diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml new file mode 100644 index 0000000000..0c43071f16 --- /dev/null +++ b/sample-operators/tomcat-operator/pom.xml @@ -0,0 +1,121 @@ + + + 4.0.0 + + + io.javaoperatorsdk + sample-operators + 5.1.5-SNAPSHOT + + + sample-tomcat-operator + jar + Operator SDK - Samples - Tomcat + Provisions Tomcat Pods and deploys Webapplications in them + + + + + io.javaoperatorsdk + operator-framework-bom + ${project.version} + pom + import + + + + + + + io.javaoperatorsdk + operator-framework + + + io.fabric8 + kubernetes-httpclient-okhttp + + + + + io.fabric8 + kubernetes-httpclient-vertx + + + org.apache.logging.log4j + log4j-slf4j2-impl + + + org.apache.logging.log4j + log4j-core + compile + + + org.takes + takes + 1.24.6 + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.awaitility + awaitility + 4.3.0 + test + + + io.javaoperatorsdk + operator-framework-junit-5 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + 0 + + + + com.google.cloud.tools + jib-maven-plugin + ${jib-maven-plugin.version} + + + gcr.io/distroless/java17-debian11 + + + tomcat-operator + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + io.fabric8 + crd-generator-maven-plugin + ${fabric8-client.version} + + + + generate + + + + + + + + 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 new file mode 100644 index 0000000000..c7f25e996c --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/DeploymentDependentResource.java @@ -0,0 +1,58 @@ +package io.javaoperatorsdk.operator.sample; + +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.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent( + informer = @Informer(labelSelector = "app.kubernetes.io/managed-by=tomcat-operator")) +public class DeploymentDependentResource + extends CRUDKubernetesDependentResource { + + private static String tomcatImage(Tomcat tomcat) { + return "tomcat:" + tomcat.getSpec().getVersion(); + } + + @Override + protected Deployment desired(Tomcat tomcat, Context context) { + Deployment deployment = + ReconcilerUtils.loadYaml(Deployment.class, getClass(), "deployment.yaml"); + final ObjectMeta tomcatMetadata = tomcat.getMetadata(); + final String tomcatName = tomcatMetadata.getName(); + deployment = + new DeploymentBuilder(deployment) + .editMetadata() + .withName(tomcatName) + .withNamespace(tomcatMetadata.getNamespace()) + .addToLabels("app", tomcatName) + .addToLabels("app.kubernetes.io/part-of", tomcatName) + .addToLabels("app.kubernetes.io/managed-by", "tomcat-operator") + .endMetadata() + .editSpec() + .editSelector() + .addToMatchLabels("app", tomcatName) + .endSelector() + .withReplicas(tomcat.getSpec().getReplicas()) + // set tomcat version + .editTemplate() + // make sure label selector matches label (which has to be matched by service selector + // too) + .editMetadata() + .addToLabels("app", tomcatName) + .endMetadata() + .editSpec() + .editFirstContainer() + .withImage(tomcatImage(tomcat)) + .endContainer() + .endSpec() + .endTemplate() + .endSpec() + .build(); + return 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 new file mode 100644 index 0000000000..bb0359458e --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/ServiceDependentResource.java @@ -0,0 +1,30 @@ +package io.javaoperatorsdk.operator.sample; + +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.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +@KubernetesDependent( + informer = @Informer(labelSelector = "app.kubernetes.io/managed-by=tomcat-operator")) +public class ServiceDependentResource extends CRUDKubernetesDependentResource { + + @Override + protected Service desired(Tomcat tomcat, Context context) { + final ObjectMeta tomcatMetadata = tomcat.getMetadata(); + return new ServiceBuilder(ReconcilerUtils.loadYaml(Service.class, getClass(), "service.yaml")) + .editMetadata() + .withName(tomcatMetadata.getName()) + .withNamespace(tomcatMetadata.getNamespace()) + .addToLabels("app.kubernetes.io/managed-by", "tomcat-operator") + .endMetadata() + .editSpec() + .addToSelector("app", tomcatMetadata.getName()) + .endSpec() + .build(); + } +} diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/Tomcat.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/Tomcat.java new file mode 100644 index 0000000000..7f60bd00d5 --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/Tomcat.java @@ -0,0 +1,20 @@ +package io.javaoperatorsdk.operator.sample; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; + +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("tomcatoperator.io") +@Version("v1") +@ShortNames("tc") +public class Tomcat extends CustomResource implements Namespaced { + + public String toString() { + return ToStringBuilder.reflectionToString(this, ToStringStyle.JSON_STYLE); + } +} diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java new file mode 100644 index 0000000000..370a488bc9 --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java @@ -0,0 +1,27 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.IOException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.takes.facets.fork.FkRegex; +import org.takes.facets.fork.TkFork; +import org.takes.http.Exit; +import org.takes.http.FtBasic; + +import io.javaoperatorsdk.operator.Operator; + +public class TomcatOperator { + + private static final Logger log = LoggerFactory.getLogger(TomcatOperator.class); + + public static void main(String[] args) throws IOException { + + Operator operator = new Operator(); + operator.register(new TomcatReconciler()); + operator.register(new WebappReconciler(operator.getKubernetesClient())); + operator.start(); + + new FtBasic(new TkFork(new FkRegex("/health", "ALL GOOD.")), 8080).start(Exit.NEVER); + } +} diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java new file mode 100644 index 0000000000..5cef5ea25c --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java @@ -0,0 +1,60 @@ +package io.javaoperatorsdk.operator.sample; + +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentStatus; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; + +/** + * Runs a specified number of Tomcat app server Pods. It uses a Deployment to create the Pods. Also + * creates a Service over which the Pods can be accessed. + */ +@Workflow( + dependents = { + @Dependent(type = DeploymentDependentResource.class), + @Dependent(type = ServiceDependentResource.class) + }) +@ControllerConfiguration +public class TomcatReconciler implements Reconciler { + + private final Logger log = LoggerFactory.getLogger(getClass()); + + @Override + public UpdateControl reconcile(Tomcat tomcat, Context context) { + return context + .getSecondaryResource(Deployment.class) + .map( + deployment -> { + Tomcat updatedTomcat = createTomcatForStatusUpdate(tomcat, deployment); + log.info( + "Updating status of Tomcat {} in namespace {} to {} ready replicas", + tomcat.getMetadata().getName(), + tomcat.getMetadata().getNamespace(), + tomcat.getStatus() == null ? 0 : tomcat.getStatus().getReadyReplicas()); + return UpdateControl.patchStatus(updatedTomcat); + }) + .orElseGet(UpdateControl::noUpdate); + } + + private Tomcat createTomcatForStatusUpdate(Tomcat tomcat, Deployment deployment) { + Tomcat res = new Tomcat(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(tomcat.getMetadata().getName()) + .withNamespace(tomcat.getMetadata().getNamespace()) + .build()); + DeploymentStatus deploymentStatus = + Objects.requireNonNullElse(deployment.getStatus(), new DeploymentStatus()); + int readyReplicas = Objects.requireNonNullElse(deploymentStatus.getReadyReplicas(), 0); + TomcatStatus status = new TomcatStatus(); + status.setReadyReplicas(readyReplicas); + res.setStatus(status); + return res; + } +} diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatSpec.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatSpec.java new file mode 100644 index 0000000000..fbd22f30f9 --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatSpec.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.operator.sample; + +public class TomcatSpec { + + private Integer version; + private Integer replicas; + + public Integer getVersion() { + return version; + } + + public void setVersion(Integer version) { + this.version = version; + } + + public Integer getReplicas() { + return replicas; + } + + public void setReplicas(Integer replicas) { + this.replicas = replicas; + } +} diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatStatus.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatStatus.java new file mode 100644 index 0000000000..3bf3d2ab4b --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatStatus.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.sample; + +public class TomcatStatus { + + private Integer readyReplicas = 0; + + public Integer getReadyReplicas() { + return readyReplicas; + } + + public void setReadyReplicas(Integer readyReplicas) { + this.readyReplicas = readyReplicas; + } +} diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/Webapp.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/Webapp.java new file mode 100644 index 0000000000..2d5ce3f925 --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/Webapp.java @@ -0,0 +1,19 @@ +package io.javaoperatorsdk.operator.sample; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +/** Represents a web application deployed in a Tomcat deployment */ +@Group("tomcatoperator.io") +@Version("v1") +public class Webapp extends CustomResource implements Namespaced { + + public String toString() { + return ToStringBuilder.reflectionToString(this, ToStringStyle.JSON_STYLE); + } +} diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java new file mode 100644 index 0000000000..0a26aece2e --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappReconciler.java @@ -0,0 +1,243 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.ExecListener; +import io.fabric8.kubernetes.client.dsl.ExecWatch; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +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.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration +public class WebappReconciler implements Reconciler, Cleaner { + + private static final Logger log = LoggerFactory.getLogger(WebappReconciler.class); + + private final KubernetesClient kubernetesClient; + + public WebappReconciler(KubernetesClient kubernetesClient) { + this.kubernetesClient = kubernetesClient; + } + + @Override + public List> prepareEventSources(EventSourceContext context) { + /* + * To create an event to a related WebApp resource and trigger the reconciliation we need to + * find which WebApp this Tomcat custom resource is related to. To find the related + * customResourceId of the WebApp resource we traverse the cache and identify it based on naming + * convention. + */ + final SecondaryToPrimaryMapper webappsMatchingTomcatName = + (Tomcat t) -> + context + .getPrimaryCache() + .list(webApp -> webApp.getSpec().getTomcat().equals(t.getMetadata().getName())) + .map(ResourceID::fromResource) + .collect(Collectors.toSet()); + + InformerEventSourceConfiguration configuration = + InformerEventSourceConfiguration.from(Tomcat.class, Webapp.class) + .withSecondaryToPrimaryMapper(webappsMatchingTomcatName) + .withPrimaryToSecondaryMapper( + (Webapp primary) -> + Set.of( + new ResourceID( + primary.getSpec().getTomcat(), primary.getMetadata().getNamespace()))) + .build(); + return List.of(new InformerEventSource<>(configuration, context)); + } + + /** + * This method will be called not only on changes to Webapp objects but also when Tomcat objects + * change. + */ + @Override + public UpdateControl reconcile(Webapp webapp, Context context) { + if (webapp.getStatus() != null + && Objects.equals(webapp.getSpec().getUrl(), webapp.getStatus().getDeployedArtifact())) { + return UpdateControl.noUpdate(); + } + + Tomcat tomcat = + context + .getSecondaryResource(Tomcat.class) + .orElseThrow( + () -> + new IllegalStateException( + "Cannot find Tomcat " + + webapp.getSpec().getTomcat() + + " for Webapp " + + webapp.getMetadata().getName() + + " in namespace " + + webapp.getMetadata().getNamespace())); + + if (tomcat.getStatus() != null + && Objects.equals(tomcat.getSpec().getReplicas(), tomcat.getStatus().getReadyReplicas())) { + log.info( + "Tomcat is ready and webapps not yet deployed. Commencing deployment of {} in Tomcat {}", + webapp.getMetadata().getName(), + tomcat.getMetadata().getName()); + String[] command = + new String[] { + "wget", + "-O", + "/data/" + webapp.getSpec().getContextPath() + ".war", + webapp.getSpec().getUrl() + }; + if (log.isInfoEnabled()) { + command = + new String[] { + "time", + "wget", + "-O", + "/data/" + webapp.getSpec().getContextPath() + ".war", + webapp.getSpec().getUrl() + }; + } + + String[] commandStatusInAllPods = executeCommandInAllPods(kubernetesClient, webapp, command); + + return UpdateControl.patchStatus(createWebAppForStatusUpdate(webapp, commandStatusInAllPods)); + } else { + log.info( + "WebappController invoked but Tomcat not ready yet ({}/{})", + tomcat.getStatus() != null ? tomcat.getStatus().getReadyReplicas() : 0, + tomcat.getSpec().getReplicas()); + return UpdateControl.noUpdate(); + } + } + + private Webapp createWebAppForStatusUpdate(Webapp actual, String[] commandStatusInAllPods) { + var webapp = new Webapp(); + webapp.setMetadata( + new ObjectMetaBuilder() + .withName(actual.getMetadata().getName()) + .withNamespace(actual.getMetadata().getNamespace()) + .build()); + webapp.setStatus(new WebappStatus()); + webapp.getStatus().setDeployedArtifact(actual.getSpec().getUrl()); + webapp.getStatus().setDeploymentStatus(commandStatusInAllPods); + return webapp; + } + + @Override + public DeleteControl cleanup(Webapp webapp, Context context) { + + String[] command = new String[] {"rm", "/data/" + webapp.getSpec().getContextPath() + ".war"}; + String[] commandStatusInAllPods = executeCommandInAllPods(kubernetesClient, webapp, command); + if (webapp.getStatus() != null) { + webapp.getStatus().setDeployedArtifact(null); + webapp.getStatus().setDeploymentStatus(commandStatusInAllPods); + } + return DeleteControl.defaultDelete(); + } + + private String[] executeCommandInAllPods( + KubernetesClient kubernetesClient, Webapp webapp, String[] command) { + String[] status = new String[0]; + + Deployment deployment = + kubernetesClient + .apps() + .deployments() + .inNamespace(webapp.getMetadata().getNamespace()) + .withName(webapp.getSpec().getTomcat()) + .get(); + + if (deployment != null) { + List pods = + kubernetesClient + .pods() + .inNamespace(webapp.getMetadata().getNamespace()) + .withLabels(deployment.getSpec().getSelector().getMatchLabels()) + .list() + .getItems(); + status = new String[pods.size()]; + for (int i = 0; i < pods.size(); i++) { + Pod pod = pods.get(i); + log.info( + "Executing command {} in Pod {}", + String.join(" ", command), + pod.getMetadata().getName()); + + CompletableFuture data = new CompletableFuture<>(); + try (ExecWatch execWatch = execCmd(pod, data, command)) { + status[i] = pod.getMetadata().getName() + ":" + data.get(30, TimeUnit.SECONDS); + } catch (ExecutionException e) { + status[i] = pod.getMetadata().getName() + ": ExecutionException - " + e.getMessage(); + } catch (InterruptedException e) { + status[i] = pod.getMetadata().getName() + ": InterruptedException - " + e.getMessage(); + } catch (TimeoutException e) { + status[i] = pod.getMetadata().getName() + ": TimeoutException - " + e.getMessage(); + } + } + } + return status; + } + + private ExecWatch execCmd(Pod pod, CompletableFuture data, String... command) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + return kubernetesClient + .pods() + .inNamespace(pod.getMetadata().getNamespace()) + .withName(pod.getMetadata().getName()) + .inContainer("war-downloader") + .writingOutput(baos) + .writingError(baos) + .usingListener(new SimpleListener(data, baos)) + .exec(command); + } + + static class SimpleListener implements ExecListener { + + private final CompletableFuture data; + private final ByteArrayOutputStream baos; + private final Logger log = LoggerFactory.getLogger(getClass()); + + public SimpleListener(CompletableFuture data, ByteArrayOutputStream baos) { + this.data = data; + this.baos = baos; + } + + @Override + public void onOpen() { + log.debug("Reading data... "); + } + + @Override + public void onFailure(Throwable t, Response response) { + log.debug(t.getMessage()); + data.completeExceptionally(t); + } + + @Override + public void onClose(int code, String reason) { + log.debug("Exit with: {} and with reason: {}", code, reason); + data.complete(baos.toString()); + } + } +} diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappSpec.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappSpec.java new file mode 100644 index 0000000000..a34621c35b --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappSpec.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.operator.sample; + +public class WebappSpec { + + private String url; + + private String contextPath; + + private String tomcat; + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getContextPath() { + return contextPath; + } + + public void setContextPath(String contextPath) { + this.contextPath = contextPath; + } + + public String getTomcat() { + return tomcat; + } + + public void setTomcat(String tomcat) { + this.tomcat = tomcat; + } +} diff --git a/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappStatus.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappStatus.java new file mode 100644 index 0000000000..8267abe24c --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/WebappStatus.java @@ -0,0 +1,24 @@ +package io.javaoperatorsdk.operator.sample; + +public class WebappStatus { + + private String deployedArtifact; + + public String getDeployedArtifact() { + return deployedArtifact; + } + + public void setDeployedArtifact(String deployedArtifact) { + this.deployedArtifact = deployedArtifact; + } + + private String[] deploymentStatus; + + public String[] getDeploymentStatus() { + return deploymentStatus; + } + + public void setDeploymentStatus(String[] deploymentStatus) { + this.deploymentStatus = deploymentStatus; + } +} diff --git a/sample-operators/tomcat-operator/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml b/sample-operators/tomcat-operator/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml new file mode 100644 index 0000000000..aa38eb3619 --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "" + labels: + app.kubernetes.io/part-of: "" + app.kubernetes.io/managed-by: "" # used for filtering of Deployments created by the controller +spec: + selector: + matchLabels: + app: "" + replicas: 1 + template: + metadata: + labels: + app: "" + spec: + containers: + - name: tomcat + image: tomcat:8.0 + ports: + - containerPort: 8080 + volumeMounts: + - mountPath: /usr/local/tomcat/webapps + name: webapps-volume + - name: war-downloader + image: busybox:1.34.1 + command: ['tail', '-f', '/dev/null'] + volumeMounts: + - name: webapps-volume + mountPath: /data + volumes: + - name: webapps-volume + emptyDir: {} diff --git a/sample-operators/tomcat-operator/src/main/resources/io/javaoperatorsdk/operator/sample/service.yaml b/sample-operators/tomcat-operator/src/main/resources/io/javaoperatorsdk/operator/sample/service.yaml new file mode 100644 index 0000000000..ab198643ed --- /dev/null +++ b/sample-operators/tomcat-operator/src/main/resources/io/javaoperatorsdk/operator/sample/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: "" +spec: + selector: + app: "" + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + type: NodePort diff --git a/samples/basic/common/src/main/resources/log4j2.xml b/sample-operators/tomcat-operator/src/main/resources/log4j2.xml similarity index 72% rename from samples/basic/common/src/main/resources/log4j2.xml rename to sample-operators/tomcat-operator/src/main/resources/log4j2.xml index 35a6a3ccdf..a99aaf31b6 100644 --- a/samples/basic/common/src/main/resources/log4j2.xml +++ b/sample-operators/tomcat-operator/src/main/resources/log4j2.xml @@ -2,7 +2,7 @@ - + @@ -10,4 +10,4 @@ - \ No newline at end of file + diff --git a/sample-operators/tomcat-operator/src/test/java/io/javaoperatorsdk/operator/sample/TomcatOperatorE2E.java b/sample-operators/tomcat-operator/src/test/java/io/javaoperatorsdk/operator/sample/TomcatOperatorE2E.java new file mode 100644 index 0000000000..a38c705898 --- /dev/null +++ b/sample-operators/tomcat-operator/src/test/java/io/javaoperatorsdk/operator/sample/TomcatOperatorE2E.java @@ -0,0 +1,139 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.DefaultKubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.javaoperatorsdk.operator.junit.AbstractOperatorExtension; +import io.javaoperatorsdk.operator.junit.ClusterDeployedOperatorExtension; +import io.javaoperatorsdk.operator.junit.InClusterCurl; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.notNullValue; + +class TomcatOperatorE2E { + + static final Logger log = LoggerFactory.getLogger(TomcatOperatorE2E.class); + + static final KubernetesClient client = new DefaultKubernetesClient(); + + public TomcatOperatorE2E() throws FileNotFoundException {} + + static final int tomcatReplicas = 2; + + boolean isLocal() { + String deployment = System.getProperty("test.deployment"); + boolean remote = (deployment != null && deployment.equals("remote")); + log.info("Running the operator " + (remote ? "remote" : "locally")); + return !remote; + } + + @RegisterExtension + AbstractOperatorExtension operator = + isLocal() + ? LocallyRunOperatorExtension.builder() + .waitForNamespaceDeletion(false) + .withReconciler(new TomcatReconciler()) + .withReconciler(new WebappReconciler(client)) + .build() + : ClusterDeployedOperatorExtension.builder() + .waitForNamespaceDeletion(false) + .withOperatorDeployment(client.load(new FileInputStream("k8s/operator.yaml")).items()) + .build(); + + Tomcat getTomcat() { + Tomcat tomcat = new Tomcat(); + tomcat.setMetadata( + new ObjectMetaBuilder() + .withName("test-tomcat1") + .withNamespace(operator.getNamespace()) + .build()); + tomcat.setSpec(new TomcatSpec()); + tomcat.getSpec().setReplicas(tomcatReplicas); + tomcat.getSpec().setVersion(9); + return tomcat; + } + + Webapp getWebapp() { + Webapp webapp1 = new Webapp(); + webapp1.setMetadata( + new ObjectMetaBuilder() + .withName("test-webapp1") + .withNamespace(operator.getNamespace()) + .build()); + webapp1.setSpec(new WebappSpec()); + webapp1.getSpec().setContextPath("webapp1"); + webapp1.getSpec().setTomcat(getTomcat().getMetadata().getName()); + webapp1.getSpec().setUrl("http://tomcat.apache.org/tomcat-7.0-doc/appdev/sample/sample.war"); + return webapp1; + } + + @Test + void test() { + var tomcat = getTomcat(); + var webapp1 = getWebapp(); + var tomcatClient = client.resources(Tomcat.class); + var webappClient = client.resources(Webapp.class); + + log.info("Creating test Tomcat object: {}", tomcat); + tomcatClient.inNamespace(operator.getNamespace()).resource(tomcat).create(); + log.info("Creating test Webapp object: {}", webapp1); + webappClient.inNamespace(operator.getNamespace()).resource(webapp1).create(); + + log.info("Waiting 5 minutes for Tomcat and Webapp CR statuses to be updated"); + await() + .atMost(5, MINUTES) + .untilAsserted( + () -> { + Tomcat updatedTomcat = + tomcatClient + .inNamespace(operator.getNamespace()) + .withName(tomcat.getMetadata().getName()) + .get(); + Webapp updatedWebapp = + webappClient + .inNamespace(operator.getNamespace()) + .withName(webapp1.getMetadata().getName()) + .get(); + assertThat(updatedTomcat.getStatus(), is(notNullValue())); + assertThat(updatedTomcat.getStatus().getReadyReplicas(), equalTo(tomcatReplicas)); + assertThat(updatedWebapp.getStatus(), is(notNullValue())); + assertThat(updatedWebapp.getStatus().getDeployedArtifact(), is(notNullValue())); + }); + + String url = + "http://" + tomcat.getMetadata().getName() + "/" + webapp1.getSpec().getContextPath() + "/"; + var inClusterCurl = new InClusterCurl(https://github.com/client,%20operator.getNamespace()); + log.info("Starting curl Pod and waiting 5 minutes for GET of {} to return 200", url); + + await("wait-for-webapp") + .atMost(6, MINUTES) + .untilAsserted( + () -> { + try { + var curlOutput = inClusterCurl.checkUrl(url); + assertThat(curlOutput, equalTo("200")); + } catch (KubernetesClientException ex) { + throw new AssertionError(ex); + } + }); + + log.info("Deleting test Tomcat object: {}", tomcat); + tomcatClient.inNamespace(operator.getNamespace()).resource(tomcat).delete(); + log.info("Deleting test Webapp object: {}", webapp1); + webappClient.inNamespace(operator.getNamespace()).resource(webapp1).delete(); + } +} diff --git a/sample-operators/tomcat-operator/src/test/resources/log4j2.xml b/sample-operators/tomcat-operator/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..a99aaf31b6 --- /dev/null +++ b/sample-operators/tomcat-operator/src/test/resources/log4j2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/sample-operators/webpage/README.md b/sample-operators/webpage/README.md new file mode 100644 index 0000000000..7718d0f2f3 --- /dev/null +++ b/sample-operators/webpage/README.md @@ -0,0 +1,78 @@ +# WebPage Operator + +This is a simple example of how a Custom Resource backed by an Operator can serve as +an abstraction layer. This Operator will use a WebPage resource, which mainly contains a +static webpage definition and creates an NGINX Deployment backed by a ConfigMap which holds +the HTML. + +This is an example input: +```yaml +apiVersion: "sample.javaoperatorsdk/v1" +kind: WebPage +metadata: + name: mynginx-hello +spec: + html: | + + + Webserver Operator + + + Hello World! + + +``` + + +### Different Flavors + +Sample contains three implementation, that are showcasing the different approaches possible with the framework, +the resulting behavior is almost identical behavior at the end: + +- [Low level API](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageDependentsWorkflowReconciler.java) +- [Using managed dependent resources](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageManagedDependentsReconciler.java) +- [Using standalone Dependent Resources](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageStandaloneDependentsReconciler.java) + +### Try + +The quickest way to try the operator is to run it on your local machine, while it connects to a local or remote +Kubernetes cluster. When you start it, it will use the current kubectl context on your machine to connect to the cluster. + +Before you run it you have to install the CRD on your cluster by running +`kubectl apply -f target/classes/META-INF/fabric8/webpages.sample.javaoperatorsdk-v1.yml`. + +The CRD is generated automatically from your code by simply adding the `crd-generator-apt` +dependency to your `pom.xml` file. + +When the Operator is running you can create some Webserver Custom Resources. You can find a sample custom resource in +`k8s/webpage.yaml`. You can create it by running `kubectl apply -f k8s/webpage.yaml` + +After the Operator has picked up the new webserver resource (see the logs) it should create the NGINX server in the +same namespace where the webserver resource is created. To connect to the server using your browser you can +run `kubectl get service` and view the service created by the Operator. It should have a NodePort configured. If you are +running a single-node cluster (e.g. Docker for Mac or Minikube) you can connect to the VM on this port to access the +page. Otherwise you can change the service to a LoadBalancer (e.g on a public cloud). + +You can also try to change the HTML code in `k8s/webpage.yaml` and do another `kubectl apply -f k8s/webpage.yaml`. +This should update the actual NGINX deployment with the new configuration. + +Note that there are multiple reconciler implementations that watch `WebPage` resources differentiated by a label. +When you create a new `WebPage` resource, make sure its label matches the active reconciler's label selector. + +If you want the Operator to be running as a deployment in your cluster, follow the below steps. + +### Build + +In order to point your docker build to minikube docker registry run: + +``` +eval $(minikube docker-env) +``` + +You can build the sample using `mvn jib:dockerBuild` this will produce a Docker image you can push to the registry +of your choice. The JAR file is built using your local Maven and JDK and then copied into the Docker image. + +### Deployment + +1. Deploy the CRD: `kubectl apply -f target/classes/META-INF/fabric8/webpages.sample.javaoperatorsdk-v1.yml` +2. Deploy the operator: `kubectl apply -f k8s/operator.yaml` diff --git a/sample-operators/webpage/k8s/operator.yaml b/sample-operators/webpage/k8s/operator.yaml new file mode 100644 index 0000000000..36d89054da --- /dev/null +++ b/sample-operators/webpage/k8s/operator.yaml @@ -0,0 +1,101 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: webpage-operator + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: webpage-operator +spec: + selector: + matchLabels: + app: webpage-operator + replicas: 1 + template: + metadata: + labels: + app: webpage-operator + spec: + serviceAccountName: webpage-operator + containers: + - name: operator + image: webpage-operator + imagePullPolicy: Never + ports: + - containerPort: 80 + startupProbe: + httpGet: + path: /startup + port: 8080 + initialDelaySeconds: 1 + periodSeconds: 2 + timeoutSeconds: 1 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + timeoutSeconds: 1 + periodSeconds: 2 + failureThreshold: 3 + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: operator-admin +subjects: +- kind: ServiceAccount + name: webpage-operator + namespace: default +roleRef: + kind: ClusterRole + name: webpage-operator + apiGroup: "" + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: webpage-operator +rules: +- apiGroups: + - "" + resources: + - deployments + - services + - configmaps + - pods + verbs: + - '*' +- apiGroups: + - "apps" + resources: + - deployments + - services + - configmaps + verbs: + - '*' +- apiGroups: + - "apiextensions.k8s.io" + resources: + - customresourcedefinitions + verbs: + - '*' +- apiGroups: + - "sample.javaoperatorsdk" + resources: + - webpages + - webpages/status + verbs: + - '*' +- apiGroups: + - "networking.k8s.io" + resources: + - ingresses + verbs: + - '*' + diff --git a/samples/webserver/crd/webserver.yaml b/sample-operators/webpage/k8s/webpage.yaml similarity index 55% rename from samples/webserver/crd/webserver.yaml rename to sample-operators/webpage/k8s/webpage.yaml index 0260bfb30f..1aa41ff67a 100644 --- a/samples/webserver/crd/webserver.yaml +++ b/sample-operators/webpage/k8s/webpage.yaml @@ -1,14 +1,18 @@ apiVersion: "sample.javaoperatorsdk/v1" -kind: WebServer +kind: WebPage metadata: +# Use labels to match the resource with different reconciler implementations: +# labels: +# low-level: "true" name: hellows spec: + exposed: false html: | Hello Operator World - Hellooooo!! Operators!! version 2 + Hello World! diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml new file mode 100644 index 0000000000..55eafa8490 --- /dev/null +++ b/sample-operators/webpage/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + + io.javaoperatorsdk + sample-operators + 5.1.5-SNAPSHOT + + + sample-webpage-operator + jar + Operator SDK - Samples - WebPage + Provisions an nginx Webserver based on a CRD with give html + + + + + io.javaoperatorsdk + operator-framework-bom + ${project.version} + pom + import + + + + + + + io.javaoperatorsdk + operator-framework + + + org.apache.logging.log4j + log4j-slf4j2-impl + + + org.apache.logging.log4j + log4j-core + compile + + + org.takes + takes + 1.24.6 + + + org.awaitility + awaitility + compile + + + io.javaoperatorsdk + operator-framework-junit-5 + test + + + + + + com.google.cloud.tools + jib-maven-plugin + ${jib-maven-plugin.version} + + + gcr.io/distroless/java17-debian11 + + + webpage-operator + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + io.fabric8 + crd-generator-maven-plugin + ${fabric8-client.version} + + + + generate + + + + + + + + diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/ErrorSimulationException.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/ErrorSimulationException.java new file mode 100644 index 0000000000..94efc05baa --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/ErrorSimulationException.java @@ -0,0 +1,8 @@ +package io.javaoperatorsdk.operator.sample; + +public class ErrorSimulationException extends Exception { + + public ErrorSimulationException(String message) { + super(message); + } +} 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 new file mode 100644 index 0000000000..6dc83aff08 --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/Utils.java @@ -0,0 +1,88 @@ +package io.javaoperatorsdk.operator.sample; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; +import io.javaoperatorsdk.operator.sample.customresource.WebPage; +import io.javaoperatorsdk.operator.sample.customresource.WebPageStatus; + +import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; + +public class Utils { + + private Utils() {} + + public static WebPage createWebPageForStatusUpdate(WebPage webPage, String configMapName) { + WebPage res = new WebPage(); + res.setMetadata( + new ObjectMetaBuilder() + .withName(webPage.getMetadata().getName()) + .withNamespace(webPage.getMetadata().getNamespace()) + .build()); + res.setStatus(createStatus(configMapName)); + return res; + } + + public static WebPageStatus createStatus(String configMapName) { + WebPageStatus status = new WebPageStatus(); + status.setHtmlConfigMap(configMapName); + status.setAreWeGood(true); + status.setErrorMessage(null); + return status; + } + + public static String configMapName(WebPage nginx) { + return nginx.getMetadata().getName() + "-html"; + } + + public static String deploymentName(WebPage nginx) { + return nginx.getMetadata().getName(); + } + + public static String serviceName(WebPage webPage) { + return webPage.getMetadata().getName(); + } + + public static ErrorStatusUpdateControl handleError(WebPage resource, Exception e) { + resource.getStatus().setErrorMessage("Error: " + e.getMessage()); + return ErrorStatusUpdateControl.patchStatus(resource); + } + + public static void simulateErrorIfRequested(WebPage webPage) throws ErrorSimulationException { + if (webPage.getSpec().getHtml().contains("error")) { + // special case just to showcase error if doing a demo + throw new ErrorSimulationException("Simulating error"); + } + } + + public static boolean isValidHtml(WebPage webPage) { + // very dummy html validation + var lowerCaseHtml = webPage.getSpec().getHtml().toLowerCase(); + return lowerCaseHtml.contains("") && lowerCaseHtml.contains(""); + } + + public static WebPage setInvalidHtmlErrorMessage(WebPage webPage) { + if (webPage.getStatus() == null) { + webPage.setStatus(new WebPageStatus()); + } + webPage.getStatus().setErrorMessage("Invalid html."); + return webPage; + } + + public static Ingress makeDesiredIngress(WebPage webPage) { + Ingress ingress = loadYaml(Ingress.class, Utils.class, "ingress.yaml"); + ingress.getMetadata().setName(webPage.getMetadata().getName()); + ingress.getMetadata().setNamespace(webPage.getMetadata().getNamespace()); + ingress + .getSpec() + .getRules() + .get(0) + .getHttp() + .getPaths() + .get(0) + .getBackend() + .getService() + .setName(serviceName(webPage)); + return ingress; + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageDependentsWorkflowReconciler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageDependentsWorkflowReconciler.java new file mode 100644 index 0000000000..e6f0730ddb --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageDependentsWorkflowReconciler.java @@ -0,0 +1,96 @@ +package io.javaoperatorsdk.operator.sample; + +import java.util.Arrays; +import java.util.List; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfigBuilder; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Workflow; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowBuilder; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.sample.customresource.WebPage; +import io.javaoperatorsdk.operator.sample.dependentresource.*; + +import static io.javaoperatorsdk.operator.sample.Utils.*; + +/** Shows how to implement reconciler using standalone dependent resources. */ +@ControllerConfiguration( + informer = + @Informer( + labelSelector = WebPageDependentsWorkflowReconciler.DEPENDENT_RESOURCE_LABEL_SELECTOR)) +@SuppressWarnings("unused") +public class WebPageDependentsWorkflowReconciler implements Reconciler { + + public static final String DEPENDENT_RESOURCE_LABEL_SELECTOR = "!low-level"; + + private KubernetesDependentResource configMapDR; + private KubernetesDependentResource deploymentDR; + private KubernetesDependentResource serviceDR; + private KubernetesDependentResource ingressDR; + + private final Workflow workflow; + + public WebPageDependentsWorkflowReconciler(KubernetesClient kubernetesClient) { + initDependentResources(kubernetesClient); + workflow = + new WorkflowBuilder() + .addDependentResource(configMapDR) + .addDependentResource(deploymentDR) + .addDependentResource(serviceDR) + .addDependentResourceAndConfigure(ingressDR) + .withReconcilePrecondition(new ExposedIngressCondition()) + .build(); + } + + @Override + public List> prepareEventSources(EventSourceContext context) { + return EventSourceUtils.dependentEventSources( + context, configMapDR, deploymentDR, serviceDR, ingressDR); + } + + @Override + public UpdateControl reconcile(WebPage webPage, Context context) + throws Exception { + simulateErrorIfRequested(webPage); + + workflow.reconcile(webPage, context); + + return UpdateControl.patchStatus( + createWebPageForStatusUpdate( + webPage, + context.getSecondaryResource(ConfigMap.class).orElseThrow().getMetadata().getName())); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + WebPage resource, Context retryInfo, Exception e) { + return handleError(resource, e); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private void initDependentResources(KubernetesClient client) { + this.configMapDR = new ConfigMapDependentResource(); + this.deploymentDR = new DeploymentDependentResource(); + this.serviceDR = new ServiceDependentResource(); + this.ingressDR = new IngressDependentResource(); + + Arrays.asList(configMapDR, deploymentDR, serviceDR, ingressDR) + .forEach( + dr -> + dr.configureWith( + new KubernetesDependentResourceConfigBuilder() + .withKubernetesDependentInformerConfig( + InformerConfiguration.builder(dr.resourceType()) + .withLabelSelector(DEPENDENT_RESOURCE_LABEL_SELECTOR) + .build()) + .build())); + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageManagedDependentsReconciler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageManagedDependentsReconciler.java new file mode 100644 index 0000000000..558914c861 --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageManagedDependentsReconciler.java @@ -0,0 +1,45 @@ +package io.javaoperatorsdk.operator.sample; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.sample.customresource.WebPage; +import io.javaoperatorsdk.operator.sample.dependentresource.*; + +import static io.javaoperatorsdk.operator.sample.Utils.*; + +/** Shows how to implement a reconciler with managed dependent resources. */ +@Workflow( + dependents = { + @Dependent(type = ConfigMapDependentResource.class), + @Dependent(type = DeploymentDependentResource.class), + @Dependent(type = ServiceDependentResource.class), + @Dependent( + type = IngressDependentResource.class, + reconcilePrecondition = ExposedIngressCondition.class) + }) +public class WebPageManagedDependentsReconciler implements Reconciler, Cleaner { + + public static final String SELECTOR = "managed"; + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + WebPage resource, Context context, Exception e) { + return handleError(resource, e); + } + + @Override + public UpdateControl reconcile(WebPage webPage, Context context) + throws Exception { + simulateErrorIfRequested(webPage); + + final var name = + context.getSecondaryResource(ConfigMap.class).orElseThrow().getMetadata().getName(); + return UpdateControl.patchStatus(createWebPageForStatusUpdate(webPage, name)); + } + + @Override + public DeleteControl cleanup(WebPage resource, Context context) { + return DeleteControl.defaultDelete(); + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java new file mode 100644 index 0000000000..1885e2d3b3 --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -0,0 +1,46 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.IOException; +import java.net.InetSocketAddress; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.sample.probes.LivenessHandler; +import io.javaoperatorsdk.operator.sample.probes.StartupHandler; + +import com.sun.net.httpserver.HttpServer; + +public class WebPageOperator { + public static final String WEBPAGE_RECONCILER_ENV = "WEBPAGE_RECONCILER"; + public static final String WEBPAGE_CLASSIC_RECONCILER_ENV_VALUE = "classic"; + public static final String WEBPAGE_MANAGED_DEPENDENT_RESOURCE_ENV_VALUE = "managed"; + private static final Logger log = LoggerFactory.getLogger(WebPageOperator.class); + + /** + * Based on env variables a different flavor of Reconciler is used, showcasing how the same logic + * can be implemented using the low level and higher level APIs. + */ + public static void main(String[] args) throws IOException { + log.info("WebServer Operator starting!"); + + Operator operator = new Operator(o -> o.withStopOnInformerErrorDuringStartup(false)); + String reconcilerEnvVar = System.getenv(WEBPAGE_RECONCILER_ENV); + if (WEBPAGE_CLASSIC_RECONCILER_ENV_VALUE.equals(reconcilerEnvVar)) { + operator.register(new WebPageReconciler()); + } else if (WEBPAGE_MANAGED_DEPENDENT_RESOURCE_ENV_VALUE.equals(reconcilerEnvVar)) { + operator.register(new WebPageManagedDependentsReconciler()); + } else { + operator.register(new WebPageStandaloneDependentsReconciler()); + } + operator.start(); + + HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); + server.createContext("/startup", new StartupHandler(operator)); + // we want to restart the operator if something goes wrong with (maybe just some) event sources + server.createContext("/healthz", new LivenessHandler(operator)); + server.setExecutor(null); + server.start(); + } +} 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 new file mode 100644 index 0000000000..bdeef954a2 --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java @@ -0,0 +1,266 @@ +package io.javaoperatorsdk.operator.sample; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +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.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.event.rate.RateLimited; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.sample.customresource.WebPage; + +import static io.javaoperatorsdk.operator.sample.Utils.*; +import static io.javaoperatorsdk.operator.sample.WebPageManagedDependentsReconciler.SELECTOR; + +/** Shows how to implement reconciler using the low level api directly. */ +@RateLimited(maxReconciliations = 2, within = 3) +@ControllerConfiguration +public class WebPageReconciler implements Reconciler { + + public static final String INDEX_HTML = "index.html"; + + private static final Logger log = LoggerFactory.getLogger(WebPageReconciler.class); + + public WebPageReconciler() {} + + @Override + public List> prepareEventSources(EventSourceContext context) { + var configMapEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from(ConfigMap.class, WebPage.class) + .withLabelSelector(SELECTOR) + .build(), + context); + var deploymentEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from(Deployment.class, WebPage.class) + .withLabelSelector(SELECTOR) + .build(), + context); + var serviceEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from(Service.class, WebPage.class) + .withLabelSelector(SELECTOR) + .build(), + context); + var ingressEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from(Ingress.class, WebPage.class) + .withLabelSelector(SELECTOR) + .build(), + context); + return List.of( + configMapEventSource, deploymentEventSource, serviceEventSource, ingressEventSource); + } + + @Override + public UpdateControl reconcile(WebPage webPage, Context context) + throws Exception { + log.info("Reconciling web page: {}", webPage); + simulateErrorIfRequested(webPage); + + if (!isValidHtml(webPage)) { + return UpdateControl.patchStatus(setInvalidHtmlErrorMessage(webPage)); + } + + String ns = webPage.getMetadata().getNamespace(); + String configMapName = configMapName(webPage); + String deploymentName = deploymentName(webPage); + + 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(); + } + + 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(); + } + } 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. + // https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/#mounted-configmaps-are-updated-automatically + if (previousConfigMap != null + && !StringUtils.equals( + previousConfigMap.getData().get(INDEX_HTML), + desiredHtmlConfigMap.getData().get(INDEX_HTML))) { + log.info("Restarting pods because HTML has changed in {}", ns); + context.getClient().pods().inNamespace(ns).withLabel("app", deploymentName(webPage)).delete(); + } + + return UpdateControl.patchStatus( + createWebPageForStatusUpdate(webPage, desiredHtmlConfigMap.getMetadata().getName())); + } + + private boolean match(Ingress desiredIngress, Ingress existingIngress) { + String desiredServiceName = + desiredIngress + .getSpec() + .getRules() + .get(0) + .getHttp() + .getPaths() + .get(0) + .getBackend() + .getService() + .getName(); + String existingServiceName = + existingIngress + .getSpec() + .getRules() + .get(0) + .getHttp() + .getPaths() + .get(0) + .getBackend() + .getService() + .getName(); + return Objects.equals(desiredServiceName, existingServiceName); + } + + private boolean match(Deployment desiredDeployment, Deployment deployment) { + if (deployment == null) { + return false; + } else { + return desiredDeployment.getSpec().getReplicas().equals(deployment.getSpec().getReplicas()) + && desiredDeployment + .getSpec() + .getTemplate() + .getSpec() + .getContainers() + .get(0) + .getImage() + .equals( + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getImage()); + } + } + + private boolean match(Service desiredService, Service service) { + if (service == null) { + return false; + } + return desiredService.getSpec().getSelector().equals(service.getSpec().getSelector()); + } + + private boolean match(ConfigMap desiredHtmlConfigMap, ConfigMap existingConfigMap) { + if (existingConfigMap == null) { + return false; + } else { + return desiredHtmlConfigMap.getData().equals(existingConfigMap.getData()); + } + } + + private Service makeDesiredService(WebPage webPage, String ns, Deployment desiredDeployment) { + Service desiredService = ReconcilerUtils.loadYaml(Service.class, getClass(), "service.yaml"); + desiredService.getMetadata().setName(serviceName(webPage)); + desiredService.getMetadata().setNamespace(ns); + desiredService.getMetadata().setLabels(lowLevelLabel()); + desiredService + .getSpec() + .setSelector(desiredDeployment.getSpec().getTemplate().getMetadata().getLabels()); + desiredService.addOwnerReference(webPage); + return desiredService; + } + + private Deployment makeDesiredDeployment( + WebPage webPage, String deploymentName, String ns, String configMapName) { + Deployment desiredDeployment = + ReconcilerUtils.loadYaml(Deployment.class, getClass(), "deployment.yaml"); + 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); + desiredDeployment + .getSpec() + .getTemplate() + .getSpec() + .getVolumes() + .get(0) + .setConfigMap(new ConfigMapVolumeSourceBuilder().withName(configMapName).build()); + desiredDeployment.addOwnerReference(webPage); + return desiredDeployment; + } + + private ConfigMap makeDesiredHtmlConfigMap(String ns, String configMapName, WebPage webPage) { + Map data = new HashMap<>(); + data.put(INDEX_HTML, webPage.getSpec().getHtml()); + ConfigMap configMap = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(configMapName) + .withNamespace(ns) + .withLabels(lowLevelLabel()) + .build()) + .withData(data) + .build(); + configMap.addOwnerReference(webPage); + return configMap; + } + + public static Map lowLevelLabel() { + Map labels = new HashMap<>(); + labels.put(SELECTOR, "true"); + return labels; + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + WebPage resource, Context context, Exception e) { + return handleError(resource, e); + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageStandaloneDependentsReconciler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageStandaloneDependentsReconciler.java new file mode 100644 index 0000000000..5ba464ca97 --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageStandaloneDependentsReconciler.java @@ -0,0 +1,119 @@ +package io.javaoperatorsdk.operator.sample; + +import java.util.Arrays; +import java.util.List; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; +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.EventSourceUtils; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfigBuilder; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Workflow; +import io.javaoperatorsdk.operator.processing.dependent.workflow.WorkflowBuilder; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.sample.customresource.WebPage; +import io.javaoperatorsdk.operator.sample.dependentresource.ConfigMapDependentResource; +import io.javaoperatorsdk.operator.sample.dependentresource.DeploymentDependentResource; +import io.javaoperatorsdk.operator.sample.dependentresource.ExposedIngressCondition; +import io.javaoperatorsdk.operator.sample.dependentresource.IngressDependentResource; +import io.javaoperatorsdk.operator.sample.dependentresource.ServiceDependentResource; + +import static io.javaoperatorsdk.operator.sample.Utils.*; +import static io.javaoperatorsdk.operator.sample.WebPageManagedDependentsReconciler.SELECTOR; + +/** Shows how to implement reconciler using standalone dependent resources and workflows. */ +@ControllerConfiguration +public class WebPageStandaloneDependentsReconciler implements Reconciler { + + private final Workflow workflow; + + public WebPageStandaloneDependentsReconciler() { + // initialize the workflow + workflow = createDependentResourcesAndWorkflow(); + } + + @Override + public List> prepareEventSources(EventSourceContext context) { + // initializes the dependents' event sources from the given context + return EventSourceUtils.eventSourcesFromWorkflow(context, workflow); + } + + @Override + public UpdateControl reconcile(WebPage webPage, Context context) + throws Exception { + // for testing purposes + simulateErrorIfRequested(webPage); + + // validate the html page and update the status with an error message if it isn't valid + if (!isValidHtml(webPage)) { + return UpdateControl.patchStatus(setInvalidHtmlErrorMessage(webPage)); + } + + // Explicitly reconcile the dependent resources. + // Calling the workflow reconciliation explicitly allows control over the workflow customization + // but also *when* dependents are reconciled (as opposed to before the main reconciler's + // reconcile method in the managed case). + // With the default configuration, this will throw an exception if one of the dependents + // couldn't be properly reconciled + workflow.reconcile(webPage, context); + + // retrieve the name of the ConfigMap secondary resource to update the status if everything went + // well + webPage.setStatus( + createStatus( + context.getSecondaryResource(ConfigMap.class).orElseThrow().getMetadata().getName())); + return UpdateControl.patchStatus(webPage); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + WebPage resource, Context retryInfo, Exception e) { + return handleError(resource, e); + } + + /** + * Initializes the dependent resources and connect them in the context of a {@link Workflow} + * + * @return the {@link Workflow} that will reconcile automatically secondary resources + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private Workflow createDependentResourcesAndWorkflow() { + // create the dependent resources + var configMapDR = new ConfigMapDependentResource(); + var deploymentDR = new DeploymentDependentResource(); + var serviceDR = new ServiceDependentResource(); + var ingressDR = new IngressDependentResource(); + + // configure them with our label selector + Arrays.asList(configMapDR, deploymentDR, serviceDR, ingressDR) + .forEach( + dr -> + dr.configureWith( + new KubernetesDependentResourceConfigBuilder() + .withKubernetesDependentInformerConfig( + InformerConfiguration.builder(dr.resourceType()) + .withLabelSelector(SELECTOR + "=true") + .build()) + .build())); + + // connect the dependent resources into a workflow, configuring them as we go + // Note the method call order is significant and configuration applies to the dependent being + // configured as defined by the method call order (in this example, the reconcile pre-condition + // that is added applies to the Ingress dependent) + return new WorkflowBuilder() + .addDependentResource(configMapDR) + .addDependentResource(deploymentDR) + .addDependentResource(serviceDR) + .addDependentResourceAndConfigure(ingressDR) + // prevent the Ingress from being created based on the linked condition (here: only if the + // `exposed` flag is set in the primary resource), delete the Ingress if it already exists + // and the condition becomes false + .withReconcilePrecondition(new ExposedIngressCondition()) + .build(); + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/customresource/WebPage.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/customresource/WebPage.java new file mode 100644 index 0000000000..08a6efbd29 --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/customresource/WebPage.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.sample.customresource; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class WebPage extends CustomResource implements Namespaced { + + @Override + public String toString() { + return "WebPage{" + "spec=" + spec + ", status=" + status + '}'; + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/customresource/WebPageSpec.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/customresource/WebPageSpec.java new file mode 100644 index 0000000000..12d2ca8d4b --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/customresource/WebPageSpec.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.sample.customresource; + +public class WebPageSpec { + + private String html; + private Boolean exposed = false; + + public String getHtml() { + return html; + } + + public void setHtml(String html) { + this.html = html; + } + + public Boolean getExposed() { + return exposed; + } + + public WebPageSpec setExposed(Boolean exposed) { + this.exposed = exposed; + return this; + } + + @Override + public String toString() { + return "WebPageSpec{" + "html='" + html + '\'' + '}'; + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/customresource/WebPageStatus.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/customresource/WebPageStatus.java new file mode 100644 index 0000000000..43ed108082 --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/customresource/WebPageStatus.java @@ -0,0 +1,50 @@ +package io.javaoperatorsdk.operator.sample.customresource; + +public class WebPageStatus { + + private String htmlConfigMap; + + private Boolean areWeGood; + + private String errorMessage; + + public String getHtmlConfigMap() { + return htmlConfigMap; + } + + public void setHtmlConfigMap(String htmlConfigMap) { + this.htmlConfigMap = htmlConfigMap; + } + + public Boolean getAreWeGood() { + return areWeGood; + } + + public void setAreWeGood(Boolean areWeGood) { + this.areWeGood = areWeGood; + } + + public String getErrorMessage() { + return errorMessage; + } + + public WebPageStatus setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + return this; + } + + @Override + public String toString() { + return "WebPageStatus{" + + "htmlConfigMap='" + + htmlConfigMap + + '\'' + + ", areWeGood='" + + areWeGood + + '\'' + + ", errorMessage='" + + errorMessage + + '\'' + + '}'; + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ConfigMapDependentResource.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ConfigMapDependentResource.java new file mode 100644 index 0000000000..0cf8faad7c --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ConfigMapDependentResource.java @@ -0,0 +1,39 @@ +package io.javaoperatorsdk.operator.sample.dependentresource; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.sample.customresource.WebPage; + +import static io.javaoperatorsdk.operator.sample.Utils.configMapName; +import static io.javaoperatorsdk.operator.sample.WebPageManagedDependentsReconciler.SELECTOR; + +// this annotation only activates when using managed dependents and is not otherwise needed +@KubernetesDependent(informer = @Informer(labelSelector = SELECTOR)) +public class ConfigMapDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected ConfigMap desired(WebPage webPage, Context context) { + Map data = new HashMap<>(); + data.put("index.html", webPage.getSpec().getHtml()); + Map labels = new HashMap<>(); + labels.put(SELECTOR, "true"); + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(configMapName(webPage)) + .withNamespace(webPage.getMetadata().getNamespace()) + .withLabels(labels) + .build()) + .withData(data) + .build(); + } +} 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 new file mode 100644 index 0000000000..4deef0f1c0 --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/DeploymentDependentResource.java @@ -0,0 +1,47 @@ +package io.javaoperatorsdk.operator.sample.dependentresource; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMapVolumeSourceBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +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.sample.Utils.configMapName; +import static io.javaoperatorsdk.operator.sample.Utils.deploymentName; +import static io.javaoperatorsdk.operator.sample.WebPageManagedDependentsReconciler.SELECTOR; + +// this annotation only activates when using managed dependents and is not otherwise needed +@KubernetesDependent(informer = @Informer(labelSelector = SELECTOR)) +public class DeploymentDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected Deployment desired(WebPage webPage, Context context) { + Map labels = new HashMap<>(); + labels.put(SELECTOR, "true"); + var deploymentName = deploymentName(webPage); + Deployment deployment = loadYaml(Deployment.class, Utils.class, "deployment.yaml"); + deployment.getMetadata().setName(deploymentName); + deployment.getMetadata().setNamespace(webPage.getMetadata().getNamespace()); + deployment.getMetadata().setLabels(labels); + deployment.getSpec().getSelector().getMatchLabels().put("app", deploymentName); + + deployment.getSpec().getTemplate().getMetadata().getLabels().put("app", deploymentName); + deployment + .getSpec() + .getTemplate() + .getSpec() + .getVolumes() + .get(0) + .setConfigMap(new ConfigMapVolumeSourceBuilder().withName(configMapName(webPage)).build()); + + return deployment; + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ExposedIngressCondition.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ExposedIngressCondition.java new file mode 100644 index 0000000000..d07adc59d6 --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ExposedIngressCondition.java @@ -0,0 +1,18 @@ +package io.javaoperatorsdk.operator.sample.dependentresource; + +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; +import io.javaoperatorsdk.operator.sample.customresource.WebPage; + +public class ExposedIngressCondition implements Condition { + + @Override + public boolean isMet( + DependentResource dependentResource, + WebPage primary, + Context context) { + return primary.getSpec().getExposed(); + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/IngressDependentResource.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/IngressDependentResource.java new file mode 100644 index 0000000000..3f3e64e8ed --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/IngressDependentResource.java @@ -0,0 +1,22 @@ +package io.javaoperatorsdk.operator.sample.dependentresource; + +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.sample.WebPageManagedDependentsReconciler; +import io.javaoperatorsdk.operator.sample.customresource.WebPage; + +import static io.javaoperatorsdk.operator.sample.Utils.makeDesiredIngress; + +// this annotation only activates when using managed dependents and is not otherwise needed +@KubernetesDependent( + informer = @Informer(labelSelector = WebPageManagedDependentsReconciler.SELECTOR)) +public class IngressDependentResource extends CRUDKubernetesDependentResource { + + @Override + protected Ingress desired(WebPage webPage, Context context) { + return makeDesiredIngress(webPage); + } +} 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 new file mode 100644 index 0000000000..01e8953fa9 --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/dependentresource/ServiceDependentResource.java @@ -0,0 +1,38 @@ +package io.javaoperatorsdk.operator.sample.dependentresource; + +import java.util.HashMap; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.Service; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +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.sample.Utils.deploymentName; +import static io.javaoperatorsdk.operator.sample.Utils.serviceName; +import static io.javaoperatorsdk.operator.sample.WebPageManagedDependentsReconciler.SELECTOR; + +// this annotation only activates when using managed dependents and is not otherwise needed +@KubernetesDependent(informer = @Informer(labelSelector = SELECTOR)) +public class ServiceDependentResource + extends io.javaoperatorsdk.operator.processing.dependent.kubernetes + .CRUDKubernetesDependentResource< + Service, WebPage> { + + @Override + protected Service desired(WebPage webPage, Context context) { + Map serviceLabels = new HashMap<>(); + serviceLabels.put(SELECTOR, "true"); + Service service = loadYaml(Service.class, Utils.class, "service.yaml"); + service.getMetadata().setName(serviceName(webPage)); + service.getMetadata().setNamespace(webPage.getMetadata().getNamespace()); + service.getMetadata().setLabels(serviceLabels); + Map labels = new HashMap<>(); + labels.put("app", deploymentName(webPage)); + service.getSpec().setSelector(labels); + return service; + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/probes/LivenessHandler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/probes/LivenessHandler.java new file mode 100644 index 0000000000..e3259e9adf --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/probes/LivenessHandler.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.sample.probes; + +import java.io.IOException; + +import io.javaoperatorsdk.operator.Operator; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +import static io.javaoperatorsdk.operator.sample.probes.StartupHandler.sendMessage; + +public class LivenessHandler implements HttpHandler { + + private final Operator operator; + + public LivenessHandler(Operator operator) { + this.operator = operator; + } + + // custom logic can be added here based on the health of event sources + @Override + public void handle(HttpExchange httpExchange) throws IOException { + if (operator.getRuntimeInfo().allEventSourcesAreHealthy()) { + sendMessage(httpExchange, 200, "healthy"); + } else { + sendMessage(httpExchange, 400, "an event source is not healthy"); + } + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/probes/StartupHandler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/probes/StartupHandler.java new file mode 100644 index 0000000000..3e7bdd4673 --- /dev/null +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/probes/StartupHandler.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.sample.probes; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import io.javaoperatorsdk.operator.Operator; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +public class StartupHandler implements HttpHandler { + + private final Operator operator; + + public StartupHandler(Operator operator) { + this.operator = operator; + } + + @Override + public void handle(HttpExchange httpExchange) throws IOException { + if (operator.getRuntimeInfo().isStarted()) { + sendMessage(httpExchange, 200, "started"); + } else { + sendMessage(httpExchange, 400, "not started yet"); + } + } + + public static void sendMessage(HttpExchange httpExchange, int code, String message) + throws IOException { + try (var outputStream = httpExchange.getResponseBody()) { + var bytes = message.getBytes(StandardCharsets.UTF_8); + httpExchange.sendResponseHeaders(code, bytes.length); + outputStream.write(bytes); + outputStream.flush(); + } + } +} diff --git a/samples/webserver/src/main/resources/com/github/containersolutions/operator/sample/deployment.yaml b/sample-operators/webpage/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml similarity index 100% rename from samples/webserver/src/main/resources/com/github/containersolutions/operator/sample/deployment.yaml rename to sample-operators/webpage/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml diff --git a/sample-operators/webpage/src/main/resources/io/javaoperatorsdk/operator/sample/ingress.yaml b/sample-operators/webpage/src/main/resources/io/javaoperatorsdk/operator/sample/ingress.yaml new file mode 100644 index 0000000000..64a49bdc3e --- /dev/null +++ b/sample-operators/webpage/src/main/resources/io/javaoperatorsdk/operator/sample/ingress.yaml @@ -0,0 +1,17 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: "" + annotations: + nginx.ingress.kubernetes.io/rewrite-target: /$1 +spec: + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: "" + port: + number: 80 \ No newline at end of file diff --git a/samples/webserver/src/main/resources/com/github/containersolutions/operator/sample/service.yaml b/sample-operators/webpage/src/main/resources/io/javaoperatorsdk/operator/sample/service.yaml similarity index 100% rename from samples/webserver/src/main/resources/com/github/containersolutions/operator/sample/service.yaml rename to sample-operators/webpage/src/main/resources/io/javaoperatorsdk/operator/sample/service.yaml diff --git a/sample-operators/webpage/src/main/resources/log4j2.xml b/sample-operators/webpage/src/main/resources/log4j2.xml new file mode 100644 index 0000000000..3e92919d3b --- /dev/null +++ b/sample-operators/webpage/src/main/resources/log4j2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorAbstractTest.java b/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorAbstractTest.java new file mode 100644 index 0000000000..c20a7aef8b --- /dev/null +++ b/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorAbstractTest.java @@ -0,0 +1,156 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.fabric8.kubernetes.client.LocalPortForward; +import io.javaoperatorsdk.operator.junit.AbstractOperatorExtension; +import io.javaoperatorsdk.operator.sample.customresource.WebPage; +import io.javaoperatorsdk.operator.sample.customresource.WebPageSpec; + +import static io.javaoperatorsdk.operator.sample.Utils.deploymentName; +import static io.javaoperatorsdk.operator.sample.Utils.serviceName; +import static io.javaoperatorsdk.operator.sample.WebPageReconciler.INDEX_HTML; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public abstract class WebPageOperatorAbstractTest { + + static final Logger log = + LoggerFactory.getLogger(WebPageOperatorStandaloneDependentResourcesE2E.class); + + static final KubernetesClient client = new KubernetesClientBuilder().build(); + public static final String TEST_PAGE = "test-page"; + public static final String TITLE1 = "Hello Operator World"; + public static final String TITLE2 = "Hello Operator World Title 2"; + public static final int WAIT_SECONDS = 360; + public static final int LONG_WAIT_SECONDS = 120; + public static final Duration POLL_INTERVAL = Duration.ofSeconds(1); + + boolean isLocal() { + String deployment = System.getProperty("test.deployment"); + boolean remote = (deployment != null && deployment.equals("remote")); + log.info("Running the operator " + (remote ? "remote" : "locally")); + return !remote; + } + + @Test + void testAddingWebPage() { + + var webPage = createWebPage(TITLE1); + operator().create(webPage); + + await() + .atMost(Duration.ofSeconds(WAIT_SECONDS)) + .pollInterval(POLL_INTERVAL) + .untilAsserted( + () -> { + var actual = operator().get(WebPage.class, TEST_PAGE); + var deployment = operator().get(Deployment.class, deploymentName(webPage)); + assertThat(actual.getStatus()).isNotNull(); + assertThat(actual.getStatus().getAreWeGood()).isTrue(); + assertThat(deployment.getSpec().getReplicas()) + .isEqualTo(deployment.getStatus().getReadyReplicas()); + }); + assertThat(httpGetForWebPage(webPage)).contains(TITLE1); + + // update part: changing title + operator().replace(createWebPage(TITLE2)); + + await() + .atMost(Duration.ofSeconds(LONG_WAIT_SECONDS)) + .pollInterval(POLL_INTERVAL) + .untilAsserted( + () -> { + String page = + operator() + .get(ConfigMap.class, Utils.configMapName(webPage)) + .getData() + .get(INDEX_HTML); + // not using portforward here since there were issues with GitHub actions + // String page = httpGetForWebPage(webPage); + assertThat(page).isNotNull().contains(TITLE2); + }); + + // delete part: deleting webpage + operator().delete(createWebPage(TITLE2)); + + await() + .atMost(Duration.ofSeconds(WAIT_SECONDS)) + .pollInterval(POLL_INTERVAL) + .untilAsserted( + () -> { + Deployment deployment = operator().get(Deployment.class, deploymentName(webPage)); + assertThat(deployment).isNull(); + }); + } + + String httpGetForWebPage(WebPage webPage) { + LocalPortForward portForward = null; + try { + portForward = + client + .services() + .inNamespace(webPage.getMetadata().getNamespace()) + .withName(serviceName(webPage)) + .portForward(80); + HttpClient httpClient = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + HttpRequest request = + HttpRequest.newBuilder() + .GET() + .uri(new URI("http://localhost/" + portForward.getLocalPort())) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()).body(); + } catch (URISyntaxException | IOException | InterruptedException e) { + return null; + } finally { + if (portForward != null) { + try { + portForward.close(); + } catch (IOException e) { + log.error("Port forward close error.", e); + } + } + } + } + + WebPage createWebPage(String title) { + WebPage webPage = new WebPage(); + webPage.setMetadata(new ObjectMeta()); + webPage.getMetadata().setName(TEST_PAGE); + webPage.getMetadata().setNamespace(operator().getNamespace()); + webPage.setSpec(new WebPageSpec()); + webPage + .getSpec() + .setHtml( + "\n" + + " \n" + + " " + + title + + "\n" + + " \n" + + " \n" + + " Hello World! \n" + + " \n" + + " "); + + return webPage; + } + + abstract AbstractOperatorExtension operator(); +} diff --git a/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorE2E.java b/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorE2E.java new file mode 100644 index 0000000000..89bbceef57 --- /dev/null +++ b/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorE2E.java @@ -0,0 +1,69 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.util.ArrayList; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.Container; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.javaoperatorsdk.operator.junit.AbstractOperatorExtension; +import io.javaoperatorsdk.operator.junit.ClusterDeployedOperatorExtension; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.sample.customresource.WebPage; + +import static io.javaoperatorsdk.operator.sample.WebPageOperator.WEBPAGE_CLASSIC_RECONCILER_ENV_VALUE; +import static io.javaoperatorsdk.operator.sample.WebPageOperator.WEBPAGE_RECONCILER_ENV; +import static io.javaoperatorsdk.operator.sample.WebPageReconciler.lowLevelLabel; + +class WebPageOperatorE2E extends WebPageOperatorAbstractTest { + + public WebPageOperatorE2E() throws FileNotFoundException {} + + @RegisterExtension + AbstractOperatorExtension operator = + isLocal() + ? LocallyRunOperatorExtension.builder() + .waitForNamespaceDeletion(false) + .withReconciler(new WebPageReconciler()) + .build() + : ClusterDeployedOperatorExtension.builder() + .waitForNamespaceDeletion(false) + .withOperatorDeployment( + client.load(new FileInputStream("k8s/operator.yaml")).items(), + resources -> { + Deployment deployment = + (Deployment) + resources.stream() + .filter(r -> r instanceof Deployment) + .findFirst() + .orElseThrow(); + Container container = + deployment.getSpec().getTemplate().getSpec().getContainers().get(0); + if (container.getEnv() == null) { + container.setEnv(new ArrayList<>()); + } + container + .getEnv() + .add( + new EnvVar( + WEBPAGE_RECONCILER_ENV, + WEBPAGE_CLASSIC_RECONCILER_ENV_VALUE, + null)); + }) + .build(); + + @Override + AbstractOperatorExtension operator() { + return operator; + } + + @Override + WebPage createWebPage(String title) { + WebPage page = super.createWebPage(title); + page.getMetadata().setLabels(lowLevelLabel()); + return page; + } +} diff --git a/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorManagedDependentResourcesE2E.java b/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorManagedDependentResourcesE2E.java new file mode 100644 index 0000000000..95217237c9 --- /dev/null +++ b/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorManagedDependentResourcesE2E.java @@ -0,0 +1,60 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.util.ArrayList; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.Container; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.javaoperatorsdk.operator.junit.AbstractOperatorExtension; +import io.javaoperatorsdk.operator.junit.ClusterDeployedOperatorExtension; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.sample.WebPageOperator.WEBPAGE_MANAGED_DEPENDENT_RESOURCE_ENV_VALUE; +import static io.javaoperatorsdk.operator.sample.WebPageOperator.WEBPAGE_RECONCILER_ENV; + +class WebPageOperatorManagedDependentResourcesE2E extends WebPageOperatorAbstractTest { + + public WebPageOperatorManagedDependentResourcesE2E() throws FileNotFoundException {} + + @RegisterExtension + AbstractOperatorExtension operator = + isLocal() + ? LocallyRunOperatorExtension.builder() + .waitForNamespaceDeletion(false) + .withReconciler(new WebPageManagedDependentsReconciler()) + .build() + : ClusterDeployedOperatorExtension.builder() + .waitForNamespaceDeletion(false) + .withOperatorDeployment( + client.load(new FileInputStream("k8s/operator.yaml")).items(), + resources -> { + Deployment deployment = + (Deployment) + resources.stream() + .filter(r -> r instanceof Deployment) + .findFirst() + .orElseThrow(); + Container container = + deployment.getSpec().getTemplate().getSpec().getContainers().get(0); + if (container.getEnv() == null) { + container.setEnv(new ArrayList<>()); + } + container + .getEnv() + .add( + new EnvVar( + WEBPAGE_RECONCILER_ENV, + WEBPAGE_MANAGED_DEPENDENT_RESOURCE_ENV_VALUE, + null)); + }) + .build(); + + @Override + AbstractOperatorExtension operator() { + return operator; + } +} diff --git a/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorStandaloneDependentResourcesE2E.java b/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorStandaloneDependentResourcesE2E.java new file mode 100644 index 0000000000..7786473257 --- /dev/null +++ b/sample-operators/webpage/src/test/java/io/javaoperatorsdk/operator/sample/WebPageOperatorStandaloneDependentResourcesE2E.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.sample; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.junit.AbstractOperatorExtension; +import io.javaoperatorsdk.operator.junit.ClusterDeployedOperatorExtension; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +class WebPageOperatorStandaloneDependentResourcesE2E extends WebPageOperatorAbstractTest { + + public WebPageOperatorStandaloneDependentResourcesE2E() throws FileNotFoundException {} + + @RegisterExtension + AbstractOperatorExtension operator = + isLocal() + ? LocallyRunOperatorExtension.builder() + .waitForNamespaceDeletion(false) + .withReconciler(new WebPageStandaloneDependentsReconciler()) + .build() + : ClusterDeployedOperatorExtension.builder() + .waitForNamespaceDeletion(false) + .withOperatorDeployment(client.load(new FileInputStream("k8s/operator.yaml")).items()) + .build(); + + @Override + AbstractOperatorExtension operator() { + return operator; + } +} diff --git a/samples/basic/common/crd/crd.yaml b/samples/basic/common/crd/crd.yaml deleted file mode 100644 index 67d2b82ecb..0000000000 --- a/samples/basic/common/crd/crd.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1beta1 -kind: CustomResourceDefinition -metadata: - name: customservices.sample.javaoperatorsdk -spec: - group: sample.javaoperatorsdk - version: v1 - scope: Namespaced - names: - plural: customservices - singular: customservice - kind: CustomService - shortNames: - - cs \ No newline at end of file diff --git a/samples/basic/common/crd/test_object.yaml b/samples/basic/common/crd/test_object.yaml deleted file mode 100644 index f8e23e387b..0000000000 --- a/samples/basic/common/crd/test_object.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: "sample.javaoperatorsdk/v1" -kind: CustomService -metadata: - name: custom-service1 -spec: - name: testservice1 - label: testlabel \ No newline at end of file diff --git a/samples/basic/common/pom.xml b/samples/basic/common/pom.xml deleted file mode 100644 index 373a09aa03..0000000000 --- a/samples/basic/common/pom.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - 4.0.0 - - - com.github.containersolutions - java-operator-sdk-basic-sample - 0.3.10-SNAPSHOT - - - operator-framework-samples-common - Operator SDK - Samples - Basic - Common Files - Files shared between samples - jar - - - 8 - 1.8 - 1.8 - - - - - com.github.containersolutions - operator-framework - ${project.version} - - - org.junit.jupiter - junit-jupiter-engine - - - org.apache.logging.log4j - log4j-slf4j-impl - - - - diff --git a/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomService.java b/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomService.java deleted file mode 100644 index 17f6e768f0..0000000000 --- a/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomService.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.client.CustomResource; - -public class CustomService extends CustomResource { - - private ServiceSpec spec; - - public ServiceSpec getSpec() { - return spec; - } - - public void setSpec(ServiceSpec spec) { - this.spec = spec; - } -} diff --git a/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomServiceController.java b/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomServiceController.java deleted file mode 100644 index 883dd31f78..0000000000 --- a/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomServiceController.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import com.github.containersolutions.operator.api.Controller; -import com.github.containersolutions.operator.api.ResourceController; -import io.fabric8.kubernetes.api.model.ServicePort; -import io.fabric8.kubernetes.api.model.ServiceSpec; -import io.fabric8.kubernetes.client.KubernetesClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Arrays; -import java.util.Optional; - -/** - * A very simple sample controller that creates a service with a label. - */ -@Controller(customResourceClass = CustomService.class, - crdName = "customservices.sample.javaoperatorsdk", - customResourceListClass = CustomServiceList.class, - customResourceDoneableClass = CustomServiceDoneable.class) -public class CustomServiceController implements ResourceController { - - public static final String KIND = "CustomService"; - private final static Logger log = LoggerFactory.getLogger(CustomServiceController.class); - - private final KubernetesClient kubernetesClient; - - public CustomServiceController(KubernetesClient kubernetesClient) { - this.kubernetesClient = kubernetesClient; - } - - @Override - public boolean deleteResource(CustomService resource) { - log.info("Execution deleteResource for: {}", resource.getMetadata().getName()); - kubernetesClient.services().inNamespace(resource.getMetadata().getNamespace()) - .withName(resource.getMetadata().getName()).delete(); - return true; - } - - @Override - public Optional createOrUpdateResource(CustomService resource) { - log.info("Execution createOrUpdateResource for: {}", resource.getMetadata().getName()); - - ServicePort servicePort = new ServicePort(); - servicePort.setPort(8080); - ServiceSpec serviceSpec = new ServiceSpec(); - serviceSpec.setPorts(Arrays.asList(servicePort)); - - kubernetesClient.services().inNamespace(resource.getMetadata().getNamespace()).createOrReplaceWithNew() - .withNewMetadata() - .withName(resource.getSpec().getName()) - .addToLabels("testLabel", resource.getSpec().getLabel()) - .endMetadata() - .withSpec(serviceSpec) - .done(); - return Optional.of(resource); - } -} diff --git a/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomServiceDoneable.java b/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomServiceDoneable.java deleted file mode 100644 index 8a1dc645f9..0000000000 --- a/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomServiceDoneable.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.api.builder.Function; -import io.fabric8.kubernetes.client.CustomResourceDoneable; - -public class CustomServiceDoneable extends CustomResourceDoneable { - public CustomServiceDoneable(CustomService resource, Function function) { - super(resource, function); - } -} diff --git a/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomServiceList.java b/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomServiceList.java deleted file mode 100644 index 4389dd20c6..0000000000 --- a/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/CustomServiceList.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.client.CustomResourceList; - -public class CustomServiceList extends CustomResourceList { -} diff --git a/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/ServiceSpec.java b/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/ServiceSpec.java deleted file mode 100644 index 4cd8228746..0000000000 --- a/samples/basic/common/src/main/java/com/github/containersolutions/operator/sample/ServiceSpec.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.github.containersolutions.operator.sample; - -public class ServiceSpec { - - private String name; - private String label; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getLabel() { - return label; - } - - public void setLabel(String label) { - this.label = label; - } -} diff --git a/samples/basic/pom.xml b/samples/basic/pom.xml deleted file mode 100644 index dc88733ed4..0000000000 --- a/samples/basic/pom.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - 4.0.0 - - - com.github.containersolutions - java-operator-sdk-samples - 0.3.10-SNAPSHOT - - - java-operator-sdk-basic-sample - Operator SDK - Samples - Basic - Very simple (simplistic) usage of the Operator SDK - pom - - - common - pure-java - spring-boot - - - diff --git a/samples/basic/pure-java/pom.xml b/samples/basic/pure-java/pom.xml deleted file mode 100644 index 77d3149c43..0000000000 --- a/samples/basic/pure-java/pom.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - 4.0.0 - - - com.github.containersolutions - java-operator-sdk-basic-sample - 0.3.10-SNAPSHOT - - - operator-framework-samples-pure-java - Operator SDK - Samples - Basic - Pure Java - Sample usage with pure java app - jar - - - 8 - 1.8 - 1.8 - - - - - com.github.containersolutions - operator-framework-samples-common - ${project.version} - - - - diff --git a/samples/basic/pure-java/src/main/java/com/github/containersolutions/operator/sample/PureJavaApplicationRunner.java b/samples/basic/pure-java/src/main/java/com/github/containersolutions/operator/sample/PureJavaApplicationRunner.java deleted file mode 100644 index e0837e6217..0000000000 --- a/samples/basic/pure-java/src/main/java/com/github/containersolutions/operator/sample/PureJavaApplicationRunner.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import com.github.containersolutions.operator.Operator; -import io.fabric8.kubernetes.client.DefaultKubernetesClient; -import io.fabric8.kubernetes.client.KubernetesClient; - -public class PureJavaApplicationRunner { - - public static void main(String[] args) { - KubernetesClient client = new DefaultKubernetesClient(); - Operator operator = new Operator(client); - operator.registerController(new CustomServiceController(client)); - } -} diff --git a/samples/basic/spring-boot/pom.xml b/samples/basic/spring-boot/pom.xml deleted file mode 100644 index ddfcd83036..0000000000 --- a/samples/basic/spring-boot/pom.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - 4.0.0 - - - com.github.containersolutions - java-operator-sdk-basic-sample - 0.3.10-SNAPSHOT - - - operator-framework-samples-spring-boot - Operator SDK - Samples - Basic - Spring Boot - Sample usage with Spring Boot - jar - - - 8 - 1.8 - 1.8 - - - - - com.github.containersolutions - operator-framework-samples-common - ${project.version} - - - com.github.containersolutions - spring-boot-operator-framework-starter - ${project.version} - - - - - - - org.springframework.boot - spring-boot-dependencies - 2.1.6.RELEASE - pom - import - - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - diff --git a/samples/basic/spring-boot/src/main/java/com/github/containersolutions/operator/sample/SampleComponent.java b/samples/basic/spring-boot/src/main/java/com/github/containersolutions/operator/sample/SampleComponent.java deleted file mode 100644 index 39a5572eef..0000000000 --- a/samples/basic/spring-boot/src/main/java/com/github/containersolutions/operator/sample/SampleComponent.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import com.github.containersolutions.operator.Operator; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.dsl.internal.CustomResourceOperationsImpl; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Component; - -/** - * This component just showcases what beans are registered. - */ -@Component -public class SampleComponent { - - private final Operator operator; - - private final KubernetesClient kubernetesClient; - - private final CustomServiceController customServiceController; - - /** - * You can use qualifier for custom resource operation in case there are more custom resources for the operator - */ - private final CustomResourceOperationsImpl customResourceOperations; - - public SampleComponent(Operator operator, KubernetesClient kubernetesClient, - CustomServiceController customServiceController, - @Qualifier(CustomServiceController.KIND) CustomResourceOperationsImpl customResourceOperations) { - this.operator = operator; - this.kubernetesClient = kubernetesClient; - this.customServiceController = customServiceController; - this.customResourceOperations = customResourceOperations; - } -} diff --git a/samples/basic/spring-boot/src/main/java/com/github/containersolutions/operator/sample/SpringBootStarterSampleApplication.java b/samples/basic/spring-boot/src/main/java/com/github/containersolutions/operator/sample/SpringBootStarterSampleApplication.java deleted file mode 100644 index 3a3731afbd..0000000000 --- a/samples/basic/spring-boot/src/main/java/com/github/containersolutions/operator/sample/SpringBootStarterSampleApplication.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import com.github.containersolutions.operator.api.Controller; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.FilterType; - -/** - * Note that we have multiple options here either we can add this component scan as seen below. Or annotate controllers - * with @Component or @Service annotation or just register the bean within a spring "@Configuration". - */ -@ComponentScan(includeFilters = {@ComponentScan.Filter(type = FilterType.ANNOTATION, value = Controller.class)}) -@SpringBootApplication -public class SpringBootStarterSampleApplication { - - public static void main(String[] args) { - SpringApplication.run(SpringBootStarterSampleApplication.class, args); - } - -} diff --git a/samples/mysql-schema/Dockerfile b/samples/mysql-schema/Dockerfile deleted file mode 100644 index 0d3c4db2d5..0000000000 --- a/samples/mysql-schema/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM openjdk:12-alpine - -ENTRYPOINT ["java", "-jar", "/usr/share/operator/operator.jar"] - -ARG JAR_FILE -ADD target/${JAR_FILE} /usr/share/operator/operator.jar - diff --git a/samples/mysql-schema/README.md b/samples/mysql-schema/README.md deleted file mode 100644 index e9ac920442..0000000000 --- a/samples/mysql-schema/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# WebServer Operator - -This is a more complex example of how a Custom Resource backed by an Operator can serve as -an abstraction layer. This Operator will use an webserver resource, which mainly contains a -static webpage definition and creates a nginx Deployment backed by a ConfigMap which holds -the html. - -This is an example input: -```yaml -apiVersion: "sample.javaoperatorsdk/v1" -kind: WebServer -metadata: - name: mynginx-hello -spec: - html: | - - - Webserver Operator - - - Hello World!! - - -``` - -### Try - -The quickest way to try the operator is to run it on your local machine, while it connects to a local or remote -Kubernetes cluster. When you start it it will use the current kubectl context on your machine to connect to the cluster. - -Before you run it you have to install the CRD on your cluster by running `kubectl apply -f crd/crd.yaml` - -When the Operator is running you can create some Webserver Custom Resources. You can find a sample custom resource in -`crd/webserver.yaml`. You can create it by running `kubectl apply -f webserver.yaml` - -After the Operator has picked up the new webserver resource (see the logs) it should create the nginx server in the -same namespace where the webserver resource is created. To connect to the server using your browser you can -run `kubectl get service` and view the service created by the Operator. It should have a NodePort configured. If you are -running a single-node cluster (e.g. Docker for Mac or Minikube) you can connect to the VM on this port to access the -page. - -You can also try to change the html code in `crd/webserver.yaml` and do another `kubectl apply -f crd/webserver.yaml`. -This should update the actual nginx deployment with new configuration. - -### Build - -You can build the sample using `mvn dockerfile:build` this will produce a Docker image you can push to the registry -of your choice. The jar file is built using your local Maven and JDK and then copied into the Docker image. - -### Deployment - -1. Deploy the CRD: kubectl apply -f crd/crd.yaml -2. Deploy the operator: kubectl apply -f k8s/deployment.yaml diff --git a/samples/mysql-schema/crd/crd.yaml b/samples/mysql-schema/crd/crd.yaml deleted file mode 100644 index a6d6472365..0000000000 --- a/samples/mysql-schema/crd/crd.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1beta1 -kind: CustomResourceDefinition -metadata: - name: schemas.mysql.sample.javaoperatorsdk -spec: - group: mysql.sample.javaoperatorsdk - version: v1 - scope: Namespaced - names: - plural: schemas - singular: schema - kind: MySQLSchema - validation: - openAPIV3Schema: - type: object - properties: - spec: - type: object - properties: - encoding: - type: string \ No newline at end of file diff --git a/samples/mysql-schema/k8s/deployment.yaml b/samples/mysql-schema/k8s/deployment.yaml deleted file mode 100644 index 06b8587dfd..0000000000 --- a/samples/mysql-schema/k8s/deployment.yaml +++ /dev/null @@ -1,65 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: mysql-schema-operator ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mysql-schema-operator - namespace: mysql-schema-operator -spec: - selector: - matchLabels: - app: mysql-schema-operator - replicas: 1 - template: - metadata: - labels: - app: mysql-schema-operator - spec: - serviceAccount: mysql-schema-operator - containers: - - name: operator - image: mysql-schema-operator - imagePullPolicy: Never - ports: - - containerPort: 80 - env: - - name: MYSQL_HOST - value: mysql.mysql - - name: MYSQL_USER - value: root - - name: MYSQL_PASSWORD - value: password - readinessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 1 - timeoutSeconds: 1 - livenessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 30 - timeoutSeconds: 1 ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: mysql-schema-operator - namespace: mysql-schema-operator ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: operator-admin -subjects: -- kind: ServiceAccount - name: mysql-schema-operator - namespace: mysql-schema-operator -roleRef: - kind: ClusterRole - name: cluster-admin - apiGroup: "" \ No newline at end of file diff --git a/samples/mysql-schema/pom.xml b/samples/mysql-schema/pom.xml deleted file mode 100644 index 5d2e6dc02b..0000000000 --- a/samples/mysql-schema/pom.xml +++ /dev/null @@ -1,107 +0,0 @@ - - - 4.0.0 - - - com.github.containersolutions - java-operator-sdk-samples - 0.3.10-SNAPSHOT - - - mysql-schema-sample - Operator SDK - Samples - MySQL Schema - Provisions new Schemas in a MySQL database - jar - - - 8 - 1.8 - 1.8 - - - - - com.github.containersolutions - operator-framework - ${project.version} - - - org.junit.jupiter - junit-jupiter-engine - 5.4.2 - - - org.apache.logging.log4j - log4j-slf4j-impl - 2.11.2 - - - org.takes - takes - 1.17 - - - mysql - mysql-connector-java - 8.0.18 - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.8.1 - - - com.spotify - dockerfile-maven-plugin - 1.4.12 - - mysql-schema-operator - latest - - ${project.build.finalName}.jar - - - - - org.apache.maven.plugins - maven-shade-plugin - 2.4.3 - - - package - - shade - - - false - - - - - com.github.containersolutions.operator.sample.MySQLSchemaOperator - 1.0 - true - - - - - - io.fabric8:openshift-client - - io/fabric8/kubernetes/client/Config* - - - - - - - - - - - diff --git a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/MySQLSchemaOperator.java b/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/MySQLSchemaOperator.java deleted file mode 100644 index d26d113124..0000000000 --- a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/MySQLSchemaOperator.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import com.github.containersolutions.operator.Operator; -import io.fabric8.kubernetes.client.Config; -import io.fabric8.kubernetes.client.ConfigBuilder; -import io.fabric8.kubernetes.client.DefaultKubernetesClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.takes.facets.fork.FkRegex; -import org.takes.facets.fork.TkFork; -import org.takes.http.Exit; -import org.takes.http.FtBasic; - -import java.io.IOException; - -public class MySQLSchemaOperator { - - private static final Logger log = LoggerFactory.getLogger(MySQLSchemaOperator.class); - - public static void main(String[] args) throws IOException { - log.info("MySQL Schema Operator starting"); - - Config config = new ConfigBuilder().withNamespace(null).build(); - Operator operator = new Operator(new DefaultKubernetesClient(config)); - operator.registerControllerForAllNamespaces(new SchemaController()); - - new FtBasic( - new TkFork(new FkRegex("/health", "ALL GOOD!")), 8080 - ).start(Exit.NEVER); - } -} diff --git a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/Schema.java b/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/Schema.java deleted file mode 100644 index e9adcabcdb..0000000000 --- a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/Schema.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.client.CustomResource; - -public class Schema extends CustomResource { - - private SchemaSpec spec; - - private SchemaStatus status; - - public SchemaSpec getSpec() { - return spec; - } - - public void setSpec(SchemaSpec spec) { - this.spec = spec; - } - - public SchemaStatus getStatus() { - return status; - } - - public void setStatus(SchemaStatus status) { - this.status = status; - } -} diff --git a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaController.java b/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaController.java deleted file mode 100644 index cc328c4433..0000000000 --- a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaController.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import com.github.containersolutions.operator.api.Controller; -import com.github.containersolutions.operator.api.ResourceController; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.Optional; - -import static java.lang.String.format; - -@Controller( - crdName = "schemas.mysql.sample.javaoperatorsdk", - customResourceClass = Schema.class, - customResourceListClass = SchemaList.class, - customResourceDoneableClass = SchemaDoneable.class) -public class SchemaController implements ResourceController { - - - private final Logger log = LoggerFactory.getLogger(getClass()); - - @Override - public Optional createOrUpdateResource(Schema schema) { - try { - Connection connection = getConnection(); - ResultSet resultSet = connection.createStatement().executeQuery( - format("SELECT schema_name FROM information_schema.schemata WHERE schema_name = \"%1$s\"", - schema.getMetadata().getName())); - if (!resultSet.first()) { - connection.createStatement().execute(format("CREATE SCHEMA `%1$s` DEFAULT CHARACTER SET %2$s", - schema.getMetadata().getName(), - schema.getSpec().getEncoding())); - - SchemaStatus status = new SchemaStatus(); - status.setUrl(format("jdbc:mysql://%1$s/%2$s", - System.getenv("MYSQL_HOST"), - schema.getMetadata().getName())); - status.setStatus("CREATED"); - schema.setStatus(status); - - log.info("Schema {} created", schema.getMetadata().getName()); - return Optional.of(schema); - } else { - return Optional.of(schema); - } - } catch (SQLException e) { - log.error("Error while creating Schema", e); - - SchemaStatus status = new SchemaStatus(); - status.setUrl(null); - status.setStatus("ERROR"); - schema.setStatus(status); - - return Optional.of(schema); - } - } - - @Override - public boolean deleteResource(Schema schema) { - log.info("Execution deleteResource for: {}", schema.getMetadata().getName()); - - try { - Connection connection = getConnection(); - connection.createStatement().execute("DROP DATABASE `" + schema.getMetadata().getName() + "`"); - log.info("Deleted Schema '{}'", schema.getMetadata().getName()); - - return true; - } catch (SQLException e) { - log.error("Error while trying to delete Schema", e); - return false; - } - } - - private Connection getConnection() throws SQLException { - return DriverManager.getConnection(format("jdbc:mysql://%1$s:%2$s?user=%3$s&password=%4$s", - System.getenv("MYSQL_HOST"), - System.getenv("MYSQL_PORT") != null ? System.getenv("MYSQL_PORT") : "3306", - System.getenv("MYSQL_USER"), - System.getenv("MYSQL_PASSWORD"))); - } - -} diff --git a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaDoneable.java b/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaDoneable.java deleted file mode 100644 index dbb411bcda..0000000000 --- a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaDoneable.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.api.builder.Function; -import io.fabric8.kubernetes.client.CustomResourceDoneable; - -public class SchemaDoneable extends CustomResourceDoneable { - public SchemaDoneable(Schema resource, Function function) { - super(resource, function); - } -} diff --git a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaList.java b/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaList.java deleted file mode 100644 index 11242af250..0000000000 --- a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaList.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.client.CustomResourceList; - -public class SchemaList extends CustomResourceList { -} diff --git a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaSpec.java b/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaSpec.java deleted file mode 100644 index 76585f1807..0000000000 --- a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaSpec.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.containersolutions.operator.sample; - -public class SchemaSpec { - - private String encoding; - - public String getEncoding() { - return encoding; - } - - public void setEncoding(String encoding) { - this.encoding = encoding; - } -} diff --git a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaStatus.java b/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaStatus.java deleted file mode 100644 index 0f8fbea817..0000000000 --- a/samples/mysql-schema/src/main/java/com/github/containersolutions/operator/sample/SchemaStatus.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.containersolutions.operator.sample; - -public class SchemaStatus { - - private String url; - - private String status; - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } -} diff --git a/samples/pom.xml b/samples/pom.xml deleted file mode 100644 index f080310fb9..0000000000 --- a/samples/pom.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - 4.0.0 - - - com.github.containersolutions - java-operator-sdk - 0.3.10-SNAPSHOT - - - java-operator-sdk-samples - Operator SDK - Samples - Sample usage of the operator sdk - pom - - - basic - webserver - mysql-schema - - - - - - org.apache.maven.plugins - maven-deploy-plugin - 2.8.2 - - true - - - - - - diff --git a/samples/webserver/Dockerfile b/samples/webserver/Dockerfile deleted file mode 100644 index 0d3c4db2d5..0000000000 --- a/samples/webserver/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM openjdk:12-alpine - -ENTRYPOINT ["java", "-jar", "/usr/share/operator/operator.jar"] - -ARG JAR_FILE -ADD target/${JAR_FILE} /usr/share/operator/operator.jar - diff --git a/samples/webserver/README.md b/samples/webserver/README.md deleted file mode 100644 index e9ac920442..0000000000 --- a/samples/webserver/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# WebServer Operator - -This is a more complex example of how a Custom Resource backed by an Operator can serve as -an abstraction layer. This Operator will use an webserver resource, which mainly contains a -static webpage definition and creates a nginx Deployment backed by a ConfigMap which holds -the html. - -This is an example input: -```yaml -apiVersion: "sample.javaoperatorsdk/v1" -kind: WebServer -metadata: - name: mynginx-hello -spec: - html: | - - - Webserver Operator - - - Hello World!! - - -``` - -### Try - -The quickest way to try the operator is to run it on your local machine, while it connects to a local or remote -Kubernetes cluster. When you start it it will use the current kubectl context on your machine to connect to the cluster. - -Before you run it you have to install the CRD on your cluster by running `kubectl apply -f crd/crd.yaml` - -When the Operator is running you can create some Webserver Custom Resources. You can find a sample custom resource in -`crd/webserver.yaml`. You can create it by running `kubectl apply -f webserver.yaml` - -After the Operator has picked up the new webserver resource (see the logs) it should create the nginx server in the -same namespace where the webserver resource is created. To connect to the server using your browser you can -run `kubectl get service` and view the service created by the Operator. It should have a NodePort configured. If you are -running a single-node cluster (e.g. Docker for Mac or Minikube) you can connect to the VM on this port to access the -page. - -You can also try to change the html code in `crd/webserver.yaml` and do another `kubectl apply -f crd/webserver.yaml`. -This should update the actual nginx deployment with new configuration. - -### Build - -You can build the sample using `mvn dockerfile:build` this will produce a Docker image you can push to the registry -of your choice. The jar file is built using your local Maven and JDK and then copied into the Docker image. - -### Deployment - -1. Deploy the CRD: kubectl apply -f crd/crd.yaml -2. Deploy the operator: kubectl apply -f k8s/deployment.yaml diff --git a/samples/webserver/crd/crd.yaml b/samples/webserver/crd/crd.yaml deleted file mode 100644 index 0ed1d373a7..0000000000 --- a/samples/webserver/crd/crd.yaml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1beta1 -kind: CustomResourceDefinition -metadata: - name: webservers.sample.javaoperatorsdk -spec: - group: sample.javaoperatorsdk - version: v1 - scope: Namespaced - names: - plural: webservers - singular: webserver - kind: WebServer - shortNames: - - ws - validation: - openAPIV3Schema: - type: object - properties: - spec: - type: object - properties: - html: - type: string \ No newline at end of file diff --git a/samples/webserver/k8s/deployment.yaml b/samples/webserver/k8s/deployment.yaml deleted file mode 100644 index cc15b2d4b6..0000000000 --- a/samples/webserver/k8s/deployment.yaml +++ /dev/null @@ -1,58 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: webserver-operator ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: webserver-operator - namespace: webserver-operator -spec: - selector: - matchLabels: - app: webserver-operator - replicas: 1 - template: - metadata: - labels: - app: webserver-operator - spec: - serviceAccount: webserver-operator - containers: - - name: operator - image: webserver-operator - imagePullPolicy: Never - ports: - - containerPort: 80 - readinessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 1 - timeoutSeconds: 1 - livenessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 30 - timeoutSeconds: 1 ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: webserver-operator - namespace: webserver-operator ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: operator-admin -subjects: -- kind: ServiceAccount - name: webserver-operator - namespace: webserver-operator -roleRef: - kind: ClusterRole - name: cluster-admin - apiGroup: "" \ No newline at end of file diff --git a/samples/webserver/pom.xml b/samples/webserver/pom.xml deleted file mode 100644 index 4fa8af989d..0000000000 --- a/samples/webserver/pom.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - 4.0.0 - - - com.github.containersolutions - java-operator-sdk-samples - 0.3.10-SNAPSHOT - - - webserver-sample - Operator SDK - Samples - Webserver - Provisions an nginx Webserver based on a CRD - jar - - - 8 - 1.8 - 1.8 - - - - - com.github.containersolutions - operator-framework - ${project.version} - - - org.junit.jupiter - junit-jupiter-engine - 5.4.2 - - - org.apache.logging.log4j - log4j-slf4j-impl - 2.11.2 - - - org.takes - takes - 1.17 - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.8.1 - - - com.spotify - dockerfile-maven-plugin - 1.4.12 - - webserver-operator - latest - - ${project.build.finalName}.jar - - - - - org.apache.maven.plugins - maven-shade-plugin - 2.4.3 - - - package - - shade - - - false - - - - - com.github.containersolutions.operator.sample.WebServerOperator - 1.0 - true - - - - - - io.fabric8:openshift-client - - io/fabric8/kubernetes/client/Config* - - - - - - - - - - - diff --git a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/ErrorSimulationException.java b/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/ErrorSimulationException.java deleted file mode 100644 index 3033c86689..0000000000 --- a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/ErrorSimulationException.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.github.containersolutions.operator.sample; - -public class ErrorSimulationException extends RuntimeException { - - public ErrorSimulationException(String message) { - super(message); - } -} diff --git a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServer.java b/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServer.java deleted file mode 100644 index 8ac9f6eb2b..0000000000 --- a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServer.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.client.CustomResource; - -public class WebServer extends CustomResource { - - private WebServerSpec spec; - - private WebServerStatus status; - - public WebServerSpec getSpec() { - return spec; - } - - public void setSpec(WebServerSpec spec) { - this.spec = spec; - } - - public WebServerStatus getStatus() { - return status; - } - - public void setStatus(WebServerStatus status) { - this.status = status; - } -} diff --git a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerController.java b/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerController.java deleted file mode 100644 index 9c5063b295..0000000000 --- a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerController.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import com.github.containersolutions.operator.api.Controller; -import com.github.containersolutions.operator.api.ResourceController; -import io.fabric8.kubernetes.api.model.*; -import io.fabric8.kubernetes.api.model.apps.Deployment; -import io.fabric8.kubernetes.api.model.apps.DoneableDeployment; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.dsl.Resource; -import io.fabric8.kubernetes.client.dsl.RollableScalableResource; -import io.fabric8.kubernetes.client.dsl.ServiceResource; -import io.fabric8.kubernetes.client.utils.Serialization; -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.io.InputStream; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -@Controller(customResourceClass = WebServer.class, - crdName = "webservers.sample.javaoperatorsdk", - customResourceListClass = WebServerList.class, - customResourceDoneableClass = WebServerDoneable.class) -public class WebServerController implements ResourceController { - - private final Logger log = LoggerFactory.getLogger(getClass()); - - private final KubernetesClient kubernetesClient; - - public WebServerController(KubernetesClient kubernetesClient) { - this.kubernetesClient = kubernetesClient; - } - - @Override - public Optional createOrUpdateResource(WebServer webServer) { - if (webServer.getSpec().getHtml().contains("error")) { - throw new ErrorSimulationException("Simulating error"); - } - - String ns = webServer.getMetadata().getNamespace(); - - Map data = new HashMap<>(); - data.put("index.html", webServer.getSpec().getHtml()); - - ConfigMap htmlConfigMap = new ConfigMapBuilder() - .withMetadata(new ObjectMetaBuilder() - .withName(configMapName(webServer)) - .withNamespace(ns) - .build()) - .withData(data) - .build(); - - Deployment deployment = loadYaml(Deployment.class, "deployment.yaml"); - deployment.getMetadata().setName(deploymentName(webServer)); - deployment.getMetadata().setNamespace(ns); - deployment.getSpec().getSelector().getMatchLabels().put("app", deploymentName(webServer)); - deployment.getSpec().getTemplate().getMetadata().getLabels().put("app", deploymentName(webServer)); - deployment.getSpec().getTemplate().getSpec().getVolumes().get(0).setConfigMap( - new ConfigMapVolumeSourceBuilder().withName(configMapName(webServer)).build()); - - Service service = loadYaml(Service.class, "service.yaml"); - service.getMetadata().setName(serviceName(webServer)); - service.getMetadata().setNamespace(ns); - service.getSpec().setSelector(deployment.getSpec().getTemplate().getMetadata().getLabels()); - - ConfigMap existingConfigMap = kubernetesClient.configMaps() - .inNamespace(htmlConfigMap.getMetadata().getNamespace()) - .withName(htmlConfigMap.getMetadata().getName()).get(); - - log.info("Creating or updating ConfigMap {} in {}", htmlConfigMap.getMetadata().getName(), ns); - kubernetesClient.configMaps().inNamespace(ns).createOrReplace(htmlConfigMap); - log.info("Creating or updating Deployment {} in {}", deployment.getMetadata().getName(), ns); - kubernetesClient.apps().deployments().inNamespace(ns).createOrReplace(deployment); - - if (kubernetesClient.services().inNamespace(ns).withName(service.getMetadata().getName()).get() == null) { - log.info("Creating Service {} in {}", service.getMetadata().getName(), ns); - kubernetesClient.services().inNamespace(ns).createOrReplace(service); - } - - if (existingConfigMap != null) { - if (!StringUtils.equals(existingConfigMap.getData().get("index.html"), htmlConfigMap.getData().get("index.html"))) { - log.info("Restarting pods because HTML has changed in {}", ns); - kubernetesClient.pods().inNamespace(ns).withLabel("app", deploymentName(webServer)).delete(); - } - } - - WebServerStatus status = new WebServerStatus(); - status.setHtmlConfigMap(htmlConfigMap.getMetadata().getName()); - status.setAreWeGood("Yes!"); - webServer.setStatus(status); -// throw new RuntimeException("Creating object failed, because it failed"); - return Optional.of(webServer); - } - - @Override - public boolean deleteResource(WebServer nginx) { - log.info("Execution deleteResource for: {}", nginx.getMetadata().getName()); - - log.info("Deleting ConfigMap {}", configMapName(nginx)); - Resource configMap = kubernetesClient.configMaps() - .inNamespace(nginx.getMetadata().getNamespace()) - .withName(configMapName(nginx)); - if (configMap.get() != null) { - configMap.delete(); - } - - log.info("Deleting Deployment {}", deploymentName(nginx)); - RollableScalableResource deployment = kubernetesClient.apps().deployments() - .inNamespace(nginx.getMetadata().getNamespace()) - .withName(deploymentName(nginx)); - if (deployment.get() != null) { - deployment.cascading(true).delete(); - } - - log.info("Deleting Service {}", serviceName(nginx)); - ServiceResource service = kubernetesClient.services() - .inNamespace(nginx.getMetadata().getNamespace()) - .withName(serviceName(nginx)); - if (service.get() != null) { - service.delete(); - } - return true; - } - - private static String configMapName(WebServer nginx) { - return nginx.getMetadata().getName() + "-html"; - } - - private static String deploymentName(WebServer nginx) { - return nginx.getMetadata().getName(); - } - - private static String serviceName(WebServer nginx) { - return nginx.getMetadata().getName(); - } - - private T loadYaml(Class clazz, String yaml) { - try (InputStream is = getClass().getResourceAsStream(yaml)) { - return Serialization.unmarshal(is, clazz); - } catch (IOException ex) { - throw new IllegalStateException("Cannot find yaml on classpath: " + yaml); - } - } -} diff --git a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerDoneable.java b/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerDoneable.java deleted file mode 100644 index 20285f5f7d..0000000000 --- a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerDoneable.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.api.builder.Function; -import io.fabric8.kubernetes.client.CustomResourceDoneable; - -public class WebServerDoneable extends CustomResourceDoneable { - public WebServerDoneable(WebServer resource, Function function) { - super(resource, function); - } -} diff --git a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerList.java b/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerList.java deleted file mode 100644 index d4b644b915..0000000000 --- a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerList.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import io.fabric8.kubernetes.client.CustomResourceList; - -public class WebServerList extends CustomResourceList { -} diff --git a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerOperator.java b/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerOperator.java deleted file mode 100644 index 10667d327d..0000000000 --- a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerOperator.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.github.containersolutions.operator.sample; - -import com.github.containersolutions.operator.Operator; -import io.fabric8.kubernetes.client.Config; -import io.fabric8.kubernetes.client.ConfigBuilder; -import io.fabric8.kubernetes.client.DefaultKubernetesClient; -import io.fabric8.kubernetes.client.KubernetesClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.takes.facets.fork.FkRegex; -import org.takes.facets.fork.TkFork; -import org.takes.http.Exit; -import org.takes.http.FtBasic; - -import java.io.IOException; - -public class WebServerOperator { - - private static final Logger log = LoggerFactory.getLogger(WebServerOperator.class); - - public static void main(String[] args) throws IOException { - log.info("WebServer Operator starting!"); - - Config config = new ConfigBuilder().withNamespace(null).build(); - KubernetesClient client = new DefaultKubernetesClient(config); - Operator operator = new Operator(client); - operator.registerControllerForAllNamespaces(new WebServerController(client)); - - new FtBasic( - new TkFork(new FkRegex("/health", "ALL GOOD!")), 8080 - ).start(Exit.NEVER); - } -} diff --git a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerSpec.java b/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerSpec.java deleted file mode 100644 index 58226fc296..0000000000 --- a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerSpec.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.containersolutions.operator.sample; - -public class WebServerSpec { - - private String html; - - public String getHtml() { - return html; - } - - public void setHtml(String html) { - this.html = html; - } -} diff --git a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerStatus.java b/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerStatus.java deleted file mode 100644 index 4f57156529..0000000000 --- a/samples/webserver/src/main/java/com/github/containersolutions/operator/sample/WebServerStatus.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.github.containersolutions.operator.sample; - -public class WebServerStatus { - - private String htmlConfigMap; - - private String areWeGood; - - private String url; - - public String getHtmlConfigMap() { - return htmlConfigMap; - } - - public void setHtmlConfigMap(String htmlConfigMap) { - this.htmlConfigMap = htmlConfigMap; - } - - public String getAreWeGood() { - return areWeGood; - } - - public void setAreWeGood(String areWeGood) { - this.areWeGood = areWeGood; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } -} diff --git a/samples/webserver/src/main/resources/com/github/containersolutions/operator/sample/ingress.yaml b/samples/webserver/src/main/resources/com/github/containersolutions/operator/sample/ingress.yaml deleted file mode 100644 index 6fd1ed93e9..0000000000 --- a/samples/webserver/src/main/resources/com/github/containersolutions/operator/sample/ingress.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2 -kind: Deployment -metadata: - name: -spec: - selector: - matchLabels: - app: - replicas: 1 - template: - metadata: - labels: - app: - spec: - containers: - - name: nginx - image: nginx:1.7.9 - ports: - - containerPort: 80 \ No newline at end of file diff --git a/spring-boot-starter/pom.xml b/spring-boot-starter/pom.xml deleted file mode 100644 index f997f04eea..0000000000 --- a/spring-boot-starter/pom.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - 4.0.0 - - - com.github.containersolutions - java-operator-sdk - 0.3.10-SNAPSHOT - - - spring-boot-operator-framework-starter - Operator SDK - Spring Boot Starter - Spring Boot starter for framework - jar - - - 5.3.2 - 8 - 1.8 - 1.8 - - - - - - org.apache.maven.plugins - maven-surefire-plugin - 2.22.2 - - - org.springframework.boot - spring-boot-maven-plugin - - - - - - - - org.springframework.boot - spring-boot-dependencies - 2.1.6.RELEASE - pom - import - - - - - - - org.springframework.boot - spring-boot-autoconfigure-processor - true - - - org.springframework.boot - spring-boot-autoconfigure - - - org.springframework.boot - spring-boot-starter-test - test - - - junit - junit - - - - - org.junit.jupiter - junit-jupiter-api - ${junit-jupiter.version} - test - - - org.junit.jupiter - junit-jupiter-engine - ${junit-jupiter.version} - test - - - com.github.containersolutions - operator-framework - ${project.version} - - - org.slf4j - slf4j-api - - - org.mockito - mockito-core - - - diff --git a/spring-boot-starter/src/main/java/com/github/containersolutions/operator/spingboot/starter/OperatorAutoConfiguration.java b/spring-boot-starter/src/main/java/com/github/containersolutions/operator/spingboot/starter/OperatorAutoConfiguration.java deleted file mode 100644 index d4c940d996..0000000000 --- a/spring-boot-starter/src/main/java/com/github/containersolutions/operator/spingboot/starter/OperatorAutoConfiguration.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.github.containersolutions.operator.spingboot.starter; - -import com.github.containersolutions.operator.Operator; -import com.github.containersolutions.operator.api.ResourceController; -import io.fabric8.kubernetes.client.ConfigBuilder; -import io.fabric8.kubernetes.client.DefaultKubernetesClient; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.dsl.internal.CustomResourceOperationsImpl; -import io.fabric8.openshift.client.DefaultOpenShiftClient; -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.support.GenericApplicationContext; - -import java.util.List; - -@Configuration -@EnableConfigurationProperties(OperatorProperties.class) -@ConditionalOnMissingBean(Operator.class) -public class OperatorAutoConfiguration { - private static final Logger log = LoggerFactory.getLogger(OperatorAutoConfiguration.class); - - @Autowired - private GenericApplicationContext genericApplicationContext; - - @Autowired - private OperatorProperties operatorProperties; - - @Autowired - private List resourceControllers; - - @Bean - @ConditionalOnMissingBean - public KubernetesClient kubernetesClient() { - ConfigBuilder config = new ConfigBuilder(); - config.withTrustCerts(operatorProperties.isTrustSelfSignedCertificates()); - if (StringUtils.isNotBlank(operatorProperties.getUsername())) { - config.withUsername(operatorProperties.getUsername()); - } - if (StringUtils.isNotBlank(operatorProperties.getPassword())) { - config.withUsername(operatorProperties.getPassword()); - } - if (StringUtils.isNotBlank(operatorProperties.getMasterUrl())) { - config.withMasterUrl(operatorProperties.getMasterUrl()); - } - KubernetesClient k8sClient = operatorProperties.isOpenshift() ? new DefaultOpenShiftClient(config.build()) : new DefaultKubernetesClient(config.build()); - return k8sClient; - } - - @Bean - public Operator operator(KubernetesClient kubernetesClient) { - Operator operator = new Operator(kubernetesClient); - resourceControllers.forEach(r -> operator.registerController(r)); - operator.getCustomResourceClients().entrySet().forEach(e -> { - // todo ensure these are registered very early - log.info("Registering CustomResourceOperationsImpl for kind: {}", e.getValue().getKind()); - genericApplicationContext.registerBean(e.getValue().getKind(), CustomResourceOperationsImpl.class, - () -> operator.getCustomResourceClients(e.getKey())); - }); - return operator; - } -} diff --git a/spring-boot-starter/src/main/java/com/github/containersolutions/operator/spingboot/starter/OperatorProperties.java b/spring-boot-starter/src/main/java/com/github/containersolutions/operator/spingboot/starter/OperatorProperties.java deleted file mode 100644 index 0207c6d99a..0000000000 --- a/spring-boot-starter/src/main/java/com/github/containersolutions/operator/spingboot/starter/OperatorProperties.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.github.containersolutions.operator.spingboot.starter; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "operator.kubernetes") -public class OperatorProperties { - - private boolean openshift = false; - private String username; - private String password; - private String masterUrl; - private boolean trustSelfSignedCertificates = false; - - public boolean isOpenshift() { - return openshift; - } - - public OperatorProperties setOpenshift(boolean openshift) { - this.openshift = openshift; - return this; - } - - public String getUsername() { - return username; - } - - public OperatorProperties setUsername(String username) { - this.username = username; - return this; - } - - public String getPassword() { - return password; - } - - public OperatorProperties setPassword(String password) { - this.password = password; - return this; - } - - public String getMasterUrl() { - return masterUrl; - } - - public OperatorProperties setMasterUrl(String masterUrl) { - this.masterUrl = masterUrl; - return this; - } - - public boolean isTrustSelfSignedCertificates() { - return trustSelfSignedCertificates; - } - - public OperatorProperties setTrustSelfSignedCertificates(boolean trustSelfSignedCertificates) { - this.trustSelfSignedCertificates = trustSelfSignedCertificates; - return this; - } -} diff --git a/spring-boot-starter/src/main/resources/META-INF/spring.factories b/spring-boot-starter/src/main/resources/META-INF/spring.factories deleted file mode 100644 index 0e07c6e6d4..0000000000 --- a/spring-boot-starter/src/main/resources/META-INF/spring.factories +++ /dev/null @@ -1,2 +0,0 @@ -org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ - com.github.containersolutions.operator.spingboot.starter.OperatorAutoConfiguration \ No newline at end of file diff --git a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/AutoconfigurationTest.java b/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/AutoconfigurationTest.java deleted file mode 100644 index e7590d3342..0000000000 --- a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/AutoconfigurationTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.github.containersolutions.operator.spingboot.starter; - -import com.github.containersolutions.operator.Operator; -import com.github.containersolutions.operator.api.ResourceController; -import io.fabric8.kubernetes.client.KubernetesClient; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -@ExtendWith(SpringExtension.class) -@SpringBootTest -public class AutoconfigurationTest { - - @Autowired - private OperatorProperties operatorProperties; - - @MockBean - private Operator operator; - - @Autowired - private KubernetesClient kubernetesClient; - - @Autowired - private List resourceControllers; - - @Test - public void configurationsLoadedProperly() { - assertEquals("user", operatorProperties.getUsername()); - assertEquals("password", operatorProperties.getPassword()); - assertEquals("http://master.url/", operatorProperties.getMasterUrl()); - } - - @Test - public void beansCreated() { - assertNotNull(kubernetesClient); - } - - @Test - public void resourceControllersAreDiscovered() { - assertEquals(1, resourceControllers.size()); - assertTrue(resourceControllers.get(0) instanceof TestController); - } - -} diff --git a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/TestApplication.java b/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/TestApplication.java deleted file mode 100644 index 72bbf53477..0000000000 --- a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/TestApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.containersolutions.operator.spingboot.starter; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class TestApplication { - - public static void main(String[] args) { - SpringApplication.run(TestApplication.class, args); - } - -} diff --git a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/TestController.java b/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/TestController.java deleted file mode 100644 index 386fa7677d..0000000000 --- a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/TestController.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.github.containersolutions.operator.spingboot.starter; - -import com.github.containersolutions.operator.api.Controller; -import com.github.containersolutions.operator.api.ResourceController; -import com.github.containersolutions.operator.spingboot.starter.model.TestResource; -import com.github.containersolutions.operator.spingboot.starter.model.TestResourceDoneable; -import com.github.containersolutions.operator.spingboot.starter.model.TestResourceList; -import io.fabric8.kubernetes.client.CustomResource; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@Component -@Controller( - crdName = "name", - customResourceClass = TestResource.class, - customResourceListClass = TestResourceList.class, - customResourceDoneableClass = TestResourceDoneable.class) -public class TestController implements ResourceController { - - @Override - public boolean deleteResource(CustomResource resource) { - return true; - } - - @Override - public Optional createOrUpdateResource(CustomResource resource) { - return Optional.empty(); - } -} diff --git a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/model/TestResource.java b/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/model/TestResource.java deleted file mode 100644 index a5fce7a525..0000000000 --- a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/model/TestResource.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.containersolutions.operator.spingboot.starter.model; - -import io.fabric8.kubernetes.client.CustomResource; - -public class TestResource extends CustomResource { - -} diff --git a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/model/TestResourceDoneable.java b/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/model/TestResourceDoneable.java deleted file mode 100644 index 972e742221..0000000000 --- a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/model/TestResourceDoneable.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.github.containersolutions.operator.spingboot.starter.model; - -import io.fabric8.kubernetes.api.builder.Function; -import io.fabric8.kubernetes.client.CustomResourceDoneable; - -public class TestResourceDoneable extends CustomResourceDoneable { - public TestResourceDoneable(TestResource resource, Function function) { - super(resource, function); - } -} diff --git a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/model/TestResourceList.java b/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/model/TestResourceList.java deleted file mode 100644 index a8983b17b2..0000000000 --- a/spring-boot-starter/src/test/java/com/github/containersolutions/operator/spingboot/starter/model/TestResourceList.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.github.containersolutions.operator.spingboot.starter.model; - -import io.fabric8.kubernetes.client.CustomResourceList; - -public class TestResourceList extends CustomResourceList { -} diff --git a/spring-boot-starter/src/test/resources/application.yaml b/spring-boot-starter/src/test/resources/application.yaml deleted file mode 100644 index deb491a67c..0000000000 --- a/spring-boot-starter/src/test/resources/application.yaml +++ /dev/null @@ -1,4 +0,0 @@ -operator.kubernetes: - username: user - password: password - masterUrl: http://master.url \ No newline at end of file