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/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 index 31b89e1b20..7aa92a409c 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -1,37 +1,49 @@ # 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: Integration & End to End tests +name: End to End tests on: pull_request: - branches: [ main ] + paths-ignore: + - 'docs/**' + - 'adr/**' + branches: [ main, next ] push: + paths-ignore: + - 'docs/**' + - 'adr/**' branches: - main + - next jobs: sample_operators_tests: strategy: matrix: - sample_dir: + 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@v2 + uses: actions/checkout@v5 - name: Setup Minikube-Kubernetes - uses: manusa/actions-setup-minikube@v2.4.3 + uses: manusa/actions-setup-minikube@v2.14.0 with: - minikube version: v1.24.0 - kubernetes version: v1.23.0 + 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@v2 + uses: actions/setup-java@v5 with: - java-version: 17 + java-version: 25 distribution: temurin cache: 'maven' @@ -39,12 +51,10 @@ jobs: run: mvn install -DskipTests - name: Run integration tests in local mode - working-directory: ${{ matrix.sample_dir }} run: | - mvn test -P end-to-end-tests + mvn test -P end-to-end-tests -pl ${{ matrix.sample }} - name: Run E2E tests as a deployment - working-directory: ${{ matrix.sample_dir }} run: | eval $(minikube -p minikube docker-env) - mvn jib:dockerBuild test -P end-to-end-tests -Dtest.deployment=remote + 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 index f6c5935cd0..79660cfb1b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -8,56 +8,27 @@ concurrency: cancel-in-progress: true on: pull_request: - branches: [ main, v1 ] + 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@v2 + - uses: actions/checkout@v5 - name: Set up Java and Maven - uses: actions/setup-java@v2 + uses: actions/setup-java@v5 with: distribution: temurin - java-version: 17 + java-version: 25 cache: 'maven' - name: Check code format run: | - ./mvnw ${MAVEN_ARGS} formatter:validate -Dconfigfile=$PWD/contributing/eclipse-google-style.xml --file pom.xml - ./mvnw ${MAVEN_ARGS} impsort:check --file pom.xml + ./mvnw ${MAVEN_ARGS} spotless:check --file pom.xml - name: Run unit tests - run: ./mvnw ${MAVEN_ARGS} -B test --file pom.xml + run: ./mvnw ${MAVEN_ARGS} clean install -Pno-apt --file pom.xml - integration_tests: - runs-on: ubuntu-latest - strategy: - matrix: - java: [ 11, 17 ] - kubernetes: [ 'v1.17.13','v1.18.20','v1.19.14','v1.20.10','v1.21.4', 'v1.22.1', 'v1.23.0' ] - exclude: - - java: 11 - kubernetes: 'v1.18.20' - - java: 11 - kubernetes: 'v1.19.14' - - java: 11 - kubernetes: 'v1.20.10' - - java: 11 - kubernetes: 'v1.21.4' - - java: 11 - kubernetes: 'v1.22.1' - steps: - - uses: actions/checkout@v2 - - name: Set up Java and Maven - uses: actions/setup-java@v2 - with: - distribution: temurin - java-version: ${{ matrix.java }} - cache: 'maven' - - name: Set up Minikube - uses: manusa/actions-setup-minikube@v2.4.3 - with: - minikube version: 'v1.24.0' - kubernetes version: ${{ matrix.kubernetes }} - driver: 'docker' - - name: Run integration tests - run: ./mvnw ${MAVEN_ARGS} -B package -P no-unit-tests --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 index b36caff8f1..e7826ce613 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,68 +5,47 @@ on: release: types: [ released ] jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - if: ${{ startsWith(github.event.release.tag_name, 'v1.' ) }} - with: - ref: "v1" - - uses: actions/checkout@v2 - if: ${{ startsWith(github.event.release.tag_name, 'v2.') }} - - name: Set up Java and Maven - uses: actions/setup-java@v2 - with: - java-version: 11 - distribution: temurin - cache: 'maven' - - name: change version to release version - # Assume that RELEASE_VERSION will have form like: "v1.0.1". So we cut the "v" - run: ./mvnw ${MAVEN_ARGS} versions:set -DnewVersion="${RELEASE_VERSION:1}" versions:commit - env: - RELEASE_VERSION: ${{ github.event.release.tag_name }} - - name: Release Maven package - uses: samuelmeuli/action-maven-publish@v1 - with: - maven_profiles: "release" - gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} - gpg_passphrase: ${{ secrets.GPG_PASSPHRASE }} - nexus_username: ${{ secrets.OSSRH_USERNAME }} - nexus_password: ${{ secrets.OSSRH_TOKEN }} - # This is separate job because there were issues with git after release step, was not able to commit changes. See history. - update-working-version: + prepare-release: runs-on: ubuntu-latest + env: + tmp_version_branch: '' + outputs: + version_branch: ${{ steps.set-version-branch.outputs.version_branch }} steps: - - uses: actions/checkout@v2 - if: ${{ startsWith(github.event.release.tag_name, 'v1.' ) }} - with: - ref: "v1" - - uses: actions/checkout@v2 - if: ${{ startsWith(github.event.release.tag_name, 'v2.') }} - - name: Set up Java and Maven - uses: actions/setup-java@v2 - with: - java-version: 11 - distribution: temurin - cache: 'maven' - - name: change version to release version + - 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: | - ./mvnw ${MAVEN_ARGS} versions:set -DnewVersion="${RELEASE_VERSION:1}" versions:commit - ./mvnw ${MAVEN_ARGS} -q build-helper:parse-version versions:set -DnewVersion=\${parsedVersion.majorVersion}.\${parsedVersion.minorVersion}.\${parsedVersion.nextIncrementalVersion}-SNAPSHOT versions:commit - 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 v1 - uses: ad-m/github-push-action@master - if: ${{ startsWith(github.event.release.tag_name, 'v1.' ) }} - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: "v1" - - name: Push changes v2 - uses: ad-m/github-push-action@master - if: ${{ startsWith(github.event.release.tag_name, 'v2.' ) }} - with: - github_token: ${{ secrets.GITHUB_TOKEN }} + 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 index df593393a6..0f560dd2cb 100644 --- a/.github/workflows/snapshot-releases.yml +++ b/.github/workflows/snapshot-releases.yml @@ -8,37 +8,43 @@ concurrency: cancel-in-progress: true on: push: - branches: [ main, v1, next ] + paths-ignore: + - 'docs/**' + branches: [ main, v1, v2, v3, next ] workflow_dispatch: jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Set up Java and Maven - uses: actions/setup-java@v2 + uses: actions/setup-java@v5 with: distribution: temurin - java-version: 11 + java-version: 21 cache: 'maven' - - name: Run unit tests - run: ./mvnw ${MAVEN_ARGS} -B test --file pom.xml + - 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@v2 + - uses: actions/checkout@v5 - name: Set up Java and Maven - uses: actions/setup-java@v2 + uses: actions/setup-java@v5 with: + java-version: 21 distribution: temurin - java-version: 11 cache: 'maven' - - name: Release Maven package - uses: samuelmeuli/action-maven-publish@v1 - with: - maven_profiles: "release" - gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} - gpg_passphrase: ${{ secrets.GPG_PASSPHRASE }} - nexus_username: ${{ secrets.OSSRH_USERNAME }} - nexus_password: ${{ secrets.OSSRH_TOKEN }} + 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 index 05c7d6f753..132575edaa 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -8,8 +8,14 @@ concurrency: cancel-in-progress: true on: push: + paths-ignore: + - 'docs/**' + - 'adr/**' branches: [ main ] pull_request: + paths-ignore: + - 'docs/**' + - 'adr/**' types: [ opened, synchronize, reopened ] jobs: @@ -17,15 +23,15 @@ jobs: 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@v2 + - uses: actions/checkout@v5 - name: Set up Java and Maven - uses: actions/setup-java@v2 + uses: actions/setup-java@v5 with: distribution: temurin - java-version: 17 + java-version: 25 cache: 'maven' - name: Cache SonarCloud packages - uses: actions/cache@v2.1.7 + uses: actions/cache@v4 with: path: ~/.sonar/cache key: ${{ runner.os }}-sonar @@ -34,5 +40,5 @@ jobs: 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 verify org.jacoco:jacoco-maven-plugin:report org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=java-operator-sdk_java-operator-sdk + 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/.github/workflows/stale-issues-and-prs.yml b/.github/workflows/stale-issues-and-prs.yml deleted file mode 100644 index 604bc4fcd6..0000000000 --- a/.github/workflows/stale-issues-and-prs.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: 'Close stale issues and PRs' -on: - workflow_dispatch: - schedule: - - cron: '30 1 * * *' - -jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@v4.1.0 - with: - days-before-issue-stale: 60 - days-before-pr-stale: 60 - days-before-issue-close: 14 - days-before-pr-close: 14 - stale-issue-message: > - This issue is stale because it has been open 60 days with no activity. - Remove stale label or comment or this will be closed in 14 days. - close-issue-message: > - This issue was closed because it has been stalled for 14 days with no activity. - stale-pr-message: > - This PR is stale because it has been open 60 days with no activity. - Remove stale label or comment or this will be closed in 14 days. - close-pr-message: > - This PR was closed because it has been stalled for 10 days with no activity. - stale-issue-label: 'stale' - exempt-issue-labels: 'needs-discussion,help wanted,never stale,feature' - stale-pr-label: 'stale' - exempt-pr-labels: 'never stale' - operations-per-run: 500 - ascending: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1f3ddc0c32..638e4a93f2 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ target/ .factorypath .mvn/wrapper/maven-wrapper.jar + +.java-version +.aider* 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/CONTRIBUTING.md b/CONTRIBUTING.md index de2fa8b733..c3a9e63545 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ unacceptable behaviour to any of the project admins or adam.sandor@container-sol ## Bugs -If you find a bug, please [open an issue](https://github.com/java-operator-sdk/java-operator-sdk/issues)! Do try +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 @@ -24,7 +24,7 @@ to include all the details needed to recreate your problem. This is likely to in ## 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/java-operator-sdk/java-operator-sdk/labels/good%20first%20issue). +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. @@ -62,10 +62,10 @@ 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 the [Eclipse Code Formatter plugin](https://github.com/krasa/EclipseCodeFormatter#instructions) - - Use [contributing/eclipse-google-style.xml](contributing/eclipse-google-style.xml) for the Eclipse formatter config file -- for *Eclipse* import [contributing/eclipse-google-style.xml](contributing/eclipse-google-style.xml) +- 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 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 e41067e9a0..5bb2758ae5 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,34 @@ -# ![java-operator-sdk](docs/assets/images/logo.png) +# ![java-operator-sdk](docs/static/images/full_logo.png) -![Java CI with Maven](https://github.com/java-operator-sdk/java-operator-sdk/workflows/Java%20CI%20with%20Maven/badge.svg) +![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) # Build Kubernetes Operators in Java Without Hassle +Java Operator SDK is a production-ready framework that makes implementing Kubernetes Operators in Java easy. + +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). + +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). + +Icon + +Java Operator SDK is a CNCF project as part of [Operator Framework](https://github.com/operator-framework). + ## Documentation Documentation can be found on the **[JOSDK WebSite](https://javaoperatorsdk.io/)**. -It's getting better every day! :) - ## Contact us 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) -**Meet us** every Thursday (17:00 CET) at our **community meeting** on [Zoom](https://zoom.us/j/8415370125) +**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!) ## How to Contribute @@ -26,23 +38,48 @@ See the [contribution](https://javaoperatorsdk.io/docs/contributing) guide on th ## What is Java Operator SDK 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 patters for an Operator. Features include: +It makes it easy to implement best practices and patterns for an Operator. Features include: * Optimal handling Kubernetes API events -* Handling dependent resources, related events, caching. +* Handling dependent resources, related events, and caching. * Automatic Retries * Smart event scheduling -* Handling Observed Generations automatically * Easy to use Error Handling * ... and everything that a batteries included framework needs -For all features and their usage see the [related section on the website](https://javaoperatorsdk.io/docs/features). +For all features and their usage see the [related sections on the website](https://javaoperatorsdk.io/docs/documentation/). ## Related Projects -Operator SDK plugin: https://github.com/operator-framework/java-operator-plugins - -Quarkus Extension: https://github.com/quarkiverse/quarkus-operator-sdk - -Spring Boot Starter: https://github.com/java-operator-sdk/operator-framework-spring-boot-starter - +* 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 + +## Projects using JOSDK + +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: + +- [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..5952379112 --- /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.2 + 3.9.11 + 3.0.0 + 3.15.2 + + + + + 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/contributing/eclipse-google-style.xml b/contributing/eclipse-google-style.xml deleted file mode 100644 index 52bedc6ebd..0000000000 --- a/contributing/eclipse-google-style.xml +++ /dev/null @@ -1,337 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/contributing/eclipse.importorder b/contributing/eclipse.importorder deleted file mode 100644 index 8a156041e9..0000000000 --- a/contributing/eclipse.importorder +++ /dev/null @@ -1,7 +0,0 @@ -0=java -1=javax -2=org -3=io -4=com -5= -6=\# diff --git a/docs/.gitignore b/docs/.gitignore index d1c9ff6a5b..40b67f41a7 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,4 +1,5 @@ -.jekyll-cache/ -_site/ -.DS_Store -.sass-cache \ No newline at end of file +/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/404.md b/docs/404.md deleted file mode 100644 index 3c3670a81b..0000000000 --- a/docs/404.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: 404 - page doesn't exist! -description: 404 - are you lost? -layout: default -permalink: /404.html ---- - -## Sorry, the page you were looking for could not be found. -{:.uk-section .uk-container .uk-text-center} - -### Return to our [home page]({{ "/" | relative_url }}), or [contact us](https://discord.gg/DacEhAy) if you can’t find what you are looking for. -{: .uk-container .uk-text-center} diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 492c587214..0000000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -javaoperatorsdk.io \ No newline at end of file diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md deleted file mode 100644 index 3fe567f1e7..0000000000 --- a/docs/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -title: Contributor Covenant Code of Conduct -description: Code of Conduct -layout: default -permalink: /coc.html ---- - -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/). - - -## 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/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/Gemfile b/docs/Gemfile deleted file mode 100644 index 2334a63fb9..0000000000 --- a/docs/Gemfile +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -source "https://rubygems.org/" - -git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } - -gem "jekyll", "~> 4.2" -gem "jekyll-github-metadata" \ No newline at end of file diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock deleted file mode 100644 index 605cd1d411..0000000000 --- a/docs/Gemfile.lock +++ /dev/null @@ -1,86 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) - colorator (1.1.0) - concurrent-ruby (1.1.9) - em-websocket (0.5.2) - eventmachine (>= 0.12.9) - http_parser.rb (~> 0.6.0) - eventmachine (1.2.7) - faraday (1.3.0) - faraday-net_http (~> 1.0) - multipart-post (>= 1.2, < 3) - ruby2_keywords - faraday-net_http (1.0.1) - ffi (1.15.3) - forwardable-extended (2.6.0) - http_parser.rb (0.6.0) - i18n (1.8.10) - concurrent-ruby (~> 1.0) - jekyll (4.2.0) - addressable (~> 2.4) - colorator (~> 1.0) - em-websocket (~> 0.5) - i18n (~> 1.0) - jekyll-sass-converter (~> 2.0) - jekyll-watch (~> 2.0) - kramdown (~> 2.3) - kramdown-parser-gfm (~> 1.0) - liquid (~> 4.0) - mercenary (~> 0.4.0) - pathutil (~> 0.9) - rouge (~> 3.0) - safe_yaml (~> 1.0) - terminal-table (~> 2.0) - jekyll-github-metadata (2.13.0) - jekyll (>= 3.4, < 5.0) - octokit (~> 4.0, != 4.4.0) - jekyll-sass-converter (2.1.0) - sassc (> 2.0.1, < 3.0) - jekyll-watch (2.2.1) - listen (~> 3.0) - kramdown (2.3.1) - rexml - kramdown-parser-gfm (1.1.0) - kramdown (~> 2.0) - liquid (4.0.3) - listen (3.5.1) - rb-fsevent (~> 0.10, >= 0.10.3) - rb-inotify (~> 0.9, >= 0.9.10) - mercenary (0.4.0) - multipart-post (2.1.1) - octokit (4.20.0) - faraday (>= 0.9) - sawyer (~> 0.8.0, >= 0.5.3) - pathutil (0.16.2) - forwardable-extended (~> 2.6) - public_suffix (4.0.6) - rb-fsevent (0.11.0) - rb-inotify (0.10.1) - ffi (~> 1.0) - rexml (3.2.5) - rouge (3.26.0) - ruby2_keywords (0.0.4) - safe_yaml (1.0.5) - sassc (2.4.0) - ffi (~> 1.9) - sawyer (0.8.2) - addressable (>= 2.3.5) - faraday (> 0.8, < 2.0) - terminal-table (2.0.0) - unicode-display_width (~> 1.1, >= 1.1.1) - unicode-display_width (1.7.0) - -PLATFORMS - ruby - universal-darwin-20 - x86_64-linux - -DEPENDENCIES - jekyll (~> 4.2) - jekyll-github-metadata - -BUNDLED WITH - 2.2.30 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/_config.yml b/docs/_config.yml deleted file mode 100644 index a2b8fba679..0000000000 --- a/docs/_config.yml +++ /dev/null @@ -1,25 +0,0 @@ ---- - -# Site settings -name: Java Operator SDK -title: Java Operator SDK -description: Build Kubernetes Operators in Java without hassle -logo: logo-white.svg -logo-icon: logo-icon.svg -permalink: /:title/ -date_format: "%b %-d, %Y" -images: /assets/images/ - - -# Rouge highlighter -markdown: kramdown -highlighter: rouge - -kramdown: - parse_block_html: true - permalink: /:title - syntax_highlighter_opts: - disable : true - -plugins: - - "jekyll-github-metadata" diff --git a/docs/_data/navbar.yml b/docs/_data/navbar.yml deleted file mode 100644 index ae95f15c5c..0000000000 --- a/docs/_data/navbar.yml +++ /dev/null @@ -1,19 +0,0 @@ -# Navbar menu navigation links - - title: Home - url: / - - title: Docs - url: /docs/getting-started - - title: Code of Conduct - url: /coc - - title: Releases - url: /releases -# Navbar buttons or social icons - - title: Join Discord - url: https://discord.gg/DacEhAy - button: default - type: discord - - title: Contribute - url: https://github.com/java-operator-sdk/java-operator-sdk - button: default - type: github - diff --git a/docs/_data/sidebar.yml b/docs/_data/sidebar.yml deleted file mode 100644 index 5bd1f2ce22..0000000000 --- a/docs/_data/sidebar.yml +++ /dev/null @@ -1,20 +0,0 @@ - # Navbar menu navigation links - - title: Intro to Operators - url: /docs/intro-operators - - title: Getting Started - url: /docs/getting-started - - title: How to use Samples - url: /docs/using-samples - - title: Features - url: /docs/features - - title: Patterns and Best Practices - url: /docs/patterns-best-practices - - title: FAQ - url: /docs/faq - - title: Architecture and Internals - url: /docs/architecture-and-internals - - title: Contributing - url: /docs/contributing - - title: Migrating from v1 to v2 - url: /docs/v2-migration - diff --git a/docs/_includes/analytics.html b/docs/_includes/analytics.html deleted file mode 100644 index 40863e94bc..0000000000 --- a/docs/_includes/analytics.html +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/docs/_includes/footer.html b/docs/_includes/footer.html deleted file mode 100644 index 34449face3..0000000000 --- a/docs/_includes/footer.html +++ /dev/null @@ -1,11 +0,0 @@ -

-
-
    - {% for link in site.data.navbar%} - {% include menuItems.html %} - {% endfor %} -
-

Released under the Apache License 2.0 -
Copyright © 2020 - {{ 'now' | date: "%Y" }} Container Solutions

-
-
diff --git a/docs/_includes/hero.html b/docs/_includes/hero.html deleted file mode 100644 index d0057b772c..0000000000 --- a/docs/_includes/hero.html +++ /dev/null @@ -1,20 +0,0 @@ -
-
- {% if site.logo %} - - {% endif %} -

[ {{ site.title }} ]

- -
-
- diff --git a/docs/_includes/heroDefault.html b/docs/_includes/heroDefault.html deleted file mode 100644 index d41d20d043..0000000000 --- a/docs/_includes/heroDefault.html +++ /dev/null @@ -1,5 +0,0 @@ -
-
-

[ {{ page.title }} ]

-
-
diff --git a/docs/_includes/links.html b/docs/_includes/links.html deleted file mode 100644 index 835fa7d2e4..0000000000 --- a/docs/_includes/links.html +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/docs/_includes/menuItems.html b/docs/_includes/menuItems.html deleted file mode 100644 index bc59ec3990..0000000000 --- a/docs/_includes/menuItems.html +++ /dev/null @@ -1,49 +0,0 @@ -{% assign domain = '' | relative_url %} -{% if link.url == page.url %} -{% assign current = ' class="uk-active"' %} -{% else %} -{% assign current = ' class=""' %} -{% endif %} -{% if link.title %} - -{% if link.url %} -{% if link.button %} - -{% else %} -{{ link.title }} -{% endif %} -{% else %} -{{ link.title }} -{% endif %} -{% if link.dropdown != null %} -
-
    - {% for item in link.dropdown %} - {% if item.url != null %} - {% assign domain = '' | relative_url %} - {% if item.url == page.url %} - {% assign current = ' class="uk-active"' %} - {% else %} - {% assign current = ' class=""' %} - {% endif %} - {{ item.title }} - {% else %} -
  • {{ item.title }}
  • - {% endif %} - {% endfor %} -
-
-{% endif %} - -{% endif %} - diff --git a/docs/_includes/navbar.html b/docs/_includes/navbar.html deleted file mode 100644 index 736396a4a6..0000000000 --- a/docs/_includes/navbar.html +++ /dev/null @@ -1,42 +0,0 @@ -
-
- -
-
-
    - {% for link in site.data.navbar %} - {% include menuItems.html %} - {% endfor %} -
    - {% for link in site.data.sidebar %} - {% include menuItems.html %} - {% endfor %} -
-
-
-
-
- diff --git a/docs/_includes/scripts.html b/docs/_includes/scripts.html deleted file mode 100644 index f3ca7b56a5..0000000000 --- a/docs/_includes/scripts.html +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html deleted file mode 100644 index 1700a6f0b3..0000000000 --- a/docs/_includes/sidebar.html +++ /dev/null @@ -1,10 +0,0 @@ - \ No newline at end of file diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html deleted file mode 100644 index 0aa2dda932..0000000000 --- a/docs/_layouts/default.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - {% if page.title %}{{ page.title }}{% else %}{{ site.title | escape }}{% endif %} - {% include links.html %} - {% include analytics.html %} - - -{% include navbar.html %} -{% include heroDefault.html %} -
-
- {{ content }} -
-
-{% include footer.html %} -{% include scripts.html %} - - diff --git a/docs/_layouts/docs.html b/docs/_layouts/docs.html deleted file mode 100644 index 1283d701d6..0000000000 --- a/docs/_layouts/docs.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - {% if page.title %}{{ page.title }}{% else %}{{ site.title | escape }}{% endif %} - {% include links.html %} - {% include analytics.html %} - - - {% include navbar.html %} - -
-
-
- -
-
- {{ content }} -
-
-
-
-
- {% include scripts.html %} - - diff --git a/docs/_layouts/homepage.html b/docs/_layouts/homepage.html deleted file mode 100644 index 2804029ac3..0000000000 --- a/docs/_layouts/homepage.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - {% if page.title %}{{ page.title }}{% else %}{{ site.title | escape }}{% endif %} - {% include links.html %} - {% include analytics.html %} - - - {% include navbar.html %} - {% include hero.html %} -
- {{ content }} -
- {% include footer.html %} - {% include scripts.html %} - - diff --git a/docs/_sass/theme/mixins.scss b/docs/_sass/theme/mixins.scss deleted file mode 100644 index 35030aa78d..0000000000 --- a/docs/_sass/theme/mixins.scss +++ /dev/null @@ -1,13 +0,0 @@ -// Mixins -// ======================================================================== - -.uk-offcanvas-bar .uk-navbar-item { - min-height: 50px; - justify-content: flex-start; -} -.full-page{ - min-height: calc(100vh - 518px) -} - - - diff --git a/docs/_sass/theme/uikit.scss b/docs/_sass/theme/uikit.scss deleted file mode 100644 index 25d543ef2f..0000000000 --- a/docs/_sass/theme/uikit.scss +++ /dev/null @@ -1,100 +0,0 @@ -// Import UIkit components - -// The commented out imports are elements that can be used but they are not necessary at the moment -// They are left in to make it easier to see what needs to be imported when creating new page elements -// for example: you want to create a form so you uncomment @import "https://github.com/uikit/components/form.scss" for the styles to apply; - -// Base -@import "https://github.com/uikit/components/variables.scss"; -@import "https://github.com/uikit/components/mixin.scss"; -@import "https://github.com/uikit/components/base.scss"; - -// Elements -@import "https://github.com/uikit/components/link.scss"; -@import "https://github.com/uikit/components/heading.scss"; -@import "https://github.com/uikit/components/divider.scss"; -@import "https://github.com/uikit/components/list.scss"; -@import "https://github.com/uikit/components/description-list.scss"; -@import "https://github.com/uikit/components/icon.scss"; -@import "https://github.com/uikit/components/button.scss"; -//@import "https://github.com/uikit/components/progress.scss"; -//@import "https://github.com/uikit/components/table.scss"; -//@import "https://github.com/uikit/components/form-range.scss"; -//@import "https://github.com/uikit/components/form.scss"; - -// Layout -@import "https://github.com/uikit/components/section.scss"; -@import "https://github.com/uikit/components/container.scss"; -@import "https://github.com/uikit/components/tile.scss"; -@import "https://github.com/uikit/components/card.scss"; - -// Common -@import "https://github.com/uikit/components/article.scss"; -//@import "https://github.com/uikit/components/close.scss"; -//@import "https://github.com/uikit/components/spinner.scss"; -//@import "https://github.com/uikit/components/totop.scss"; -//@import "https://github.com/uikit/components/marker.scss"; -//@import "https://github.com/uikit/components/alert.scss"; -//@import "https://github.com/uikit/components/placeholder.scss"; -//@import "https://github.com/uikit/components/badge.scss"; -//@import "https://github.com/uikit/components/label.scss"; -//@import "https://github.com/uikit/components/overlay.scss"; -//@import "https://github.com/uikit/components/comment.scss"; -//@import "https://github.com/uikit/components/search.scss"; - -// JavaScript -@import "https://github.com/uikit/components/modal.scss"; -@import "https://github.com/uikit/components/sticky.scss"; -@import "https://github.com/uikit/components/offcanvas.scss"; -@import "https://github.com/uikit/components/leader.scss"; -//@import "https://github.com/uikit/components/accordion.scss"; -//@import "https://github.com/uikit/components/drop.scss"; -//@import "https://github.com/uikit/components/dropdown.scss"; -//@import "https://github.com/uikit/components/slideshow.scss"; -//@import "https://github.com/uikit/components/slider.scss"; -//@import "https://github.com/uikit/components/switcher.scss"; -//@import "https://github.com/uikit/components/notification.scss"; -//@import "https://github.com/uikit/components/tooltip.scss"; -//@import "https://github.com/uikit/components/sortable.scss"; -//@import "https://github.com/uikit/components/countdown.scss"; -// Scrollspy -// Toggle -// Scroll - -@import "https://github.com/uikit/components/grid.scss"; - -// Navs -@import "https://github.com/uikit/components/nav.scss"; -@import "https://github.com/uikit/components/navbar.scss"; -@import "https://github.com/uikit/components/subnav.scss"; -//@import "https://github.com/uikit/components/breadcrumb.scss"; -//@import "https://github.com/uikit/components/pagination.scss"; -//@import "https://github.com/uikit/components/tab.scss"; -//@import "https://github.com/uikit/components/slidenav.scss"; -//@import "https://github.com/uikit/components/dotnav.scss"; -//@import "https://github.com/uikit/components/thumbnav.scss"; -//@import "https://github.com/uikit/components/iconnav.scss"; - -//@import "https://github.com/uikit/components/lightbox.scss"; - -// Utilities - -@import "https://github.com/uikit/components/width.scss"; -@import "https://github.com/uikit/components/height.scss"; -@import "https://github.com/uikit/components/text.scss"; -@import "https://github.com/uikit/components/background.scss"; -@import "https://github.com/uikit/components/align.scss"; -@import "https://github.com/uikit/components/svg.scss"; -@import "https://github.com/uikit/components/utility.scss"; -@import "https://github.com/uikit/components/flex.scss"; -@import "https://github.com/uikit/components/margin.scss"; -@import "https://github.com/uikit/components/padding.scss"; -@import "https://github.com/uikit/components/position.scss"; -@import "https://github.com/uikit/components/visibility.scss"; -//@import "https://github.com/uikit/components/transition.scss"; -//@import "https://github.com/uikit/components/column.scss"; -//@import "https://github.com/uikit/components/cover.scss"; -//@import "https://github.com/uikit/components/animation.scss"; -//@import "https://github.com/uikit/components/inverse.scss"; - -//@import "https://github.com/uikit/components/print.scss"; \ No newline at end of file diff --git a/docs/_sass/theme/variables.scss b/docs/_sass/theme/variables.scss deleted file mode 100644 index a33b84d199..0000000000 --- a/docs/_sass/theme/variables.scss +++ /dev/null @@ -1,138 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=Asap:wght@700&display=swap'); - -// Global variables -$global-primary-background: #fc9c62; -$global-secondary-background: darken($global-primary-background, 60%); -$global-success-background: #32d296; -$global-muted-background: #FEF6EB; -$global-warning-background: #faa05a; -$global-danger-background: #cc2b48; -$global-font-family: 'Open sans', san-serif; -$global-link-color: darken($global-primary-background, 25%); -$global-link-hover-color: darken($global-primary-background, 30%); -$global-small-font-size: 0.875rem; -$global-muted-color: #ffffff; - -$global-xxlarge-font-size: 2.75rem; -$global-xlarge-font-size: 2rem; -$global-large-font-size: 1.5rem; -$global-medium-font-size: 1.25rem; -$xsmall-font-size: 0.875rem; - -// Base variables -$base-body-font-family: 'Open sans', san-serif; -$base-body-font-size: 1rem; -$base-body-font-weight: 400; -$base-body-line-height: 1.7; -$base-heading-font-family: 'Open sans', san-serif; -$base-heading-font-weight: 600; -$base-heading-color: $global-secondary-background; -$base-h1-line-height: 1.5; -$base-h2-line-height: 1.5; -$base-link-hover-text-decoration: none; -$base-body-color: $global-secondary-background; -$base-code-font-family: 'Open sans', san-serif; -$base-code-color: $global-secondary-background; -$base-code-font-size: $xsmall-font-size; -$base-pre-font-size: $xsmall-font-size; -$base-pre-line-height: 1.65; -$base-pre-font-family: $base-code-font-family; -$base-pre-color: $base-code-color; -$border-light: $global-muted-color; -$border-rounded-border-radius: 2px; -$text-lead-font-size: 1.125rem; -$link-muted-hover-color: $global-secondary-background; -$link-text-hover-color: #9e9aaa; -$overlay-primary-background: rgba(34,34,34,0.8); -$logo-font-family: 'Asap', sans-serif; - -// Accordion variables -$accordion-item-margin-top: 20px; -$accordion-title-font-size: 1.1875rem; -$accordion-title-color: $global-link-color; -$accordion-title-hover-color: $global-link-color; -$accordion-content-margin-top: 20px; -$accordion-icon-color: $global-primary-background; -$accordion-icon-background-color: lighten( $global-primary-background, 57% ); -$internal-accordion-open-image: "data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline fill='none' stroke='$global-secondary-background' stroke-width='1.03' points='4 13 10 7 16 13' /%3E%3C/svg%3E"; -$internal-accordion-close-image: "data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolyline fill='none' stroke='$global-secondary-background' stroke-width='1.03' points='16 7 10 13 4 7' /%3E%3C/svg%3E"; - -// Article variables -$article-title-font-size-m: 2.625rem; -$article-title-line-height: 1.4; -$article-meta-font-size: 0.8125rem; -$article-meta-line-height: 1.6; - -// Button variables -$button-font-weight: 400; -$button-default-color: $global-secondary-background; -$button-default-hover-background: $button-default-color; -$button-default-hover-color: $global-muted-background; -$button-default-active-background: $button-default-color; -$button-default-active-color: $global-muted-background; -$button-default-border: $button-default-color; -$button-default-hover-border: $button-default-color; -$button-default-active-border: $button-default-color; -$button-warning-background: $global-warning-background; -$button-warning-hover-background: darken($button-warning-background, 7%); -$button-success-background: $global-success-background; -$button-success-hover-background: darken($button-success-background, 5%); -$inverse-button-primary-color: $global-secondary-background; -$inverse-button-primary-hover-color: $global-secondary-background; - -// Card variables -$card-small-body-padding-horizontal: 25px; -$card-small-body-padding-vertical: 25px; -$card-title-font-size: 1.25rem; -$card-default-title-color: $base-heading-color; -$card-default-color: $base-body-color; - -// Heading variables -$heading-large-font-size-l: 5rem; -$heading-divider-border-width: 1px; -$heading-divider-border: #d0d5de; - -// Logo variables -$logo-font-size: 1.35rem; -$logo-color: $global-secondary-background; -$logo-hover-color: $global-secondary-background; -$inverse-logo-color: $global-muted-color; -$inverse-logo-hover-color: $global-muted-color; - -// Navigation variables -$nav-primary-item-font-size: $global-large-font-size; -$navbar-background: $global-primary-background; -$navbar-nav-item-height: 60px; -$navbar-nav-item-color: $global-muted-color; -$navbar-nav-item-active-color: $global-secondary-background; -$navbar-nav-item-hover-color: lighten($global-secondary-background, 20%); -$navbar-nav-item-font-size: 0.8rem; -$navbar-nav-item-text-transform: uppercase; -$navbar-toggle-color: lighten($global-secondary-background, 30%); -$navbar-toggle-hover-color: $global-secondary-background; -$navbar-dropdown-nav-item-color: $base-body-color; -$offcanvas-bar-color-mode: dark; -$offcanvas-bar-background: $global-muted-color; -$subnav-item-font-size: $global-small-font-size; -$subnav-item-text-transform: uppercase; -$subnav-item-active-color: lighten($global-secondary-background, 30%); -$subnav-item-hover-color: lighten($global-secondary-background, 20%); -$link-muted-hover-color : lighten($global-secondary-background, 20%); - -// Search variables -$search-default-background: $global-muted-color; -$search-default-focus-background: $global-muted-color; -$inverse-search-default-background: rgba(0, 0, 0, 0.3); -$inverse-search-default-focus-background: rgba(0, 0, 0, 0.5); - -// Section variables -$section-success-background: $global-success-background; -$section-success-color-mode: light; -$section-danger-background: $global-danger-background; -$section-danger-color-mode: light; -$section-large-padding-vertical-m: 120px; - -// Sidebar variables -$sidebar-width: 200px; -$sidebar-width-l: 300px; \ No newline at end of file diff --git a/docs/_sass/uikit/components/_buttons.scss b/docs/_sass/uikit/components/_buttons.scss deleted file mode 100644 index 00a4800604..0000000000 --- a/docs/_sass/uikit/components/_buttons.scss +++ /dev/null @@ -1,33 +0,0 @@ -.button { - background-color: #2841f9; - border: 0; - border-radius: 0; - display: inline-block; - height: 30px; - line-height: inherit; - margin-left: 5px; - margin-right: 15px; - padding: 0px; - white-space: nowrap; - width: 30px; -} - -.button-text, -input[type=submit] { - background-color: #000; - border: 0; - border-radius: 20px; - box-shadow: 0; - color: #fff !important; - display: inline-block; - font-size: 20px; - line-height: 30px; - margin: 10px 0px; - padding: 10px 30px; - - &:hover { - color: #fff; - text-decoration: none; - } -} - diff --git a/docs/_sass/uikit/components/_content.scss b/docs/_sass/uikit/components/_content.scss deleted file mode 100644 index f7be03c6cc..0000000000 --- a/docs/_sass/uikit/components/_content.scss +++ /dev/null @@ -1,120 +0,0 @@ -.content { - align-items: center; - font-size: 22px; - line-height: 30px; - justify-content: space-between; - margin-left: auto; - margin-right: auto; - max-width: 940px; - padding: 80px 0px; - - h1 { - font-size: 32px; - font-weight: bold; - line-height: 36px; - margin: 20px 0px 30px; - text-transform: uppercase; - } - - table { - width: 100%; - - td { - float: none; - width: 50%; - - a { - color: #000; - margin: 20px; - vertical-align: middle; - - &:hover { - color: #000; - text-decoration: none; - } - } - } - - @media all and (max-width: 700px) { - td { - float: left; - width: 100%; - } - } - } - - ul.plusminus { - list-style-type: none; - margin: 0; - padding: 0; - - li.minus { - padding: 0px 0px 0px 40px; - position: relative; - - &:before { - content: "-"; - font-weight: 800; - left: 16px; - position: absolute; - } - } - - li.plus { - padding: 0px 0px 0px 40px; - position: relative; - - &:before { - content: "+"; - font-weight: 800; - left: 16px; - position: absolute; - } - } - } -} - -.book-section { - background-color: #000; - background-image: url("#{$baseurl}/assets/images/pattern%20white_1.png"); - background-size: cover; - color: #fff; - font-weight: 600; - - a { - color: #fff; - text-decoration: underline; - - &:hover { - color: #fff; - } - } - - div { - background-color: transparent; - background-image: none; - } -} - -.cta-section { - text-align: center; - - strong { - text-transform: uppercase; - } -} - -.patterns-section { - a { - color: #000; - font-size: 32px; - font-weight: 600; - line-height: 36px; - margin: 30px 0px 0px; - - &:hover { - color: #000; - text-decoration: none; - } - } -} diff --git a/docs/_sass/uikit/components/_footer.scss b/docs/_sass/uikit/components/_footer.scss deleted file mode 100644 index 93216c2f02..0000000000 --- a/docs/_sass/uikit/components/_footer.scss +++ /dev/null @@ -1,49 +0,0 @@ -.footer-strip { - background-image: url("#{$baseurl}/assets/images/bg.png"); - background-size: cover; - color: #fff; - font-weight: 700; - font-size: 15px; -} - -.footer-menu { - align-items: center; - display: block; - flex-direction: row; - justify-content: space-between; - margin-left: auto; - margin-right: auto; - max-width: 940px; - - ul { - list-style: none; - margin: 0; - padding: 0; - text-align: center; - - li { - display: inline-block; - padding: 20px; - - a { - color: #ffffff; - text-decoration: none; - } - } - } -} - -.footer-title { - align-items: center; - display: block; - flex-direction: row; - justify-content: space-between; - margin-left: auto; - margin-right: auto; - max-width: 940px; - text-align: right; - - img { - height: 40px - } -} diff --git a/docs/_sass/uikit/components/_header.scss b/docs/_sass/uikit/components/_header.scss deleted file mode 100644 index 59cd4c60e1..0000000000 --- a/docs/_sass/uikit/components/_header.scss +++ /dev/null @@ -1,19 +0,0 @@ -.header-strip { - background-color: #fff; - color: #000; - font-weight: 800; - position: sticky; - top: 0px; - width: 100%; - z-index: 9999999; -} - -.header { - margin-left: auto; - margin-right: auto; - max-width: 940px; - - img { - height: 40px - } -} diff --git a/docs/_sass/uikit/components/_import.components.scss b/docs/_sass/uikit/components/_import.components.scss deleted file mode 100644 index c24ec00469..0000000000 --- a/docs/_sass/uikit/components/_import.components.scss +++ /dev/null @@ -1,56 +0,0 @@ -// Base -@import "https://github.com/variables"; -@import "https://github.com/mixin"; -@import "https://github.com/base"; - -// Elements -@import "https://github.com/link"; -@import "https://github.com/heading"; -@import "https://github.com/divider"; -@import "https://github.com/list"; -@import "https://github.com/description-list"; -@import "https://github.com/table"; -@import "https://github.com/icon"; -@import "https://github.com/form"; // After: Icon -@import "https://github.com/button"; - -// Layout -@import "https://github.com/section"; -@import "https://github.com/container"; -@import "https://github.com/grid"; -@import "https://github.com/tile"; -@import "https://github.com/card"; - -// Common -@import "https://github.com/close"; // After: Icon -@import "https://github.com/spinner"; // After: Icon -@import "https://github.com/totop"; // After: Icon -@import "https://github.com/alert"; // After: Close -@import "https://github.com/badge"; -@import "https://github.com/label"; -@import "https://github.com/overlay"; // After: Icon -@import "https://github.com/article"; // After: Subnav -@import "https://github.com/comment"; // After: Subnav -@import "https://github.com/search"; // After: Icon - -// Navs -@import "https://github.com/nav"; -@import "https://github.com/navbar"; // After: Card, Grid, Nav, Icon, Search -@import "https://github.com/subnav"; -@import "https://github.com/breadcrumb"; -@import "https://github.com/pagination"; -@import "https://github.com/tab"; -@import "https://github.com/slidenav"; // After: Icon -@import "https://github.com/dotnav"; - -// JavaScript -@import "https://github.com/accordion"; -@import "https://github.com/drop"; // After: Card -@import "https://github.com/dropdown"; // After: Card -@import "https://github.com/modal"; // After: Close -@import "https://github.com/sticky"; -@import "https://github.com/offcanvas"; -@import "https://github.com/switcher"; -// Scrollspy -// Toggle -// Scroll diff --git a/docs/_sass/uikit/components/_import.scss b/docs/_sass/uikit/components/_import.scss deleted file mode 100644 index fe35f261cf..0000000000 --- a/docs/_sass/uikit/components/_import.scss +++ /dev/null @@ -1,94 +0,0 @@ -// Base -@import "https://github.com/variables"; -@import "https://github.com/mixin"; -@import "https://github.com/base"; - -// Elements -@import "https://github.com/link"; -@import "https://github.com/heading"; -@import "https://github.com/divider"; -@import "https://github.com/list"; -@import "https://github.com/description-list"; -@import "https://github.com/table"; -@import "https://github.com/icon"; -@import "https://github.com/form-range"; -@import "https://github.com/form"; // After: Icon, Form Range -@import "https://github.com/button"; -@import "https://github.com/progress"; - -// Layout -@import "https://github.com/section"; -@import "https://github.com/container"; -@import "https://github.com/tile"; -@import "https://github.com/card"; - -// Common -@import "https://github.com/close"; // After: Icon -@import "https://github.com/spinner"; // After: Icon -@import "https://github.com/totop"; // After: Icon -@import "https://github.com/marker"; // After: Icon -@import "https://github.com/alert"; // After: Close -@import "https://github.com/placeholder"; -@import "https://github.com/badge"; -@import "https://github.com/label"; -@import "https://github.com/overlay"; // After: Icon -@import "https://github.com/article"; -@import "https://github.com/comment"; -@import "https://github.com/search"; // After: Icon - -// JavaScript -@import "https://github.com/accordion"; -@import "https://github.com/drop"; // After: Card -@import "https://github.com/dropdown"; // After: Card -@import "https://github.com/modal"; // After: Close -@import "https://github.com/slideshow"; -@import "https://github.com/slider"; -@import "https://github.com/sticky"; -@import "https://github.com/offcanvas"; -@import "https://github.com/switcher"; -@import "https://github.com/leader"; -@import "https://github.com/notification"; -@import "https://github.com/tooltip"; -@import "https://github.com/sortable"; -@import "https://github.com/countdown"; -// Scrollspy -// Toggle -// Scroll - -@import "https://github.com/grid"; - -// Navs -@import "https://github.com/nav"; -@import "https://github.com/navbar"; // After: Card, Grid, Nav, Icon, Search -@import "https://github.com/subnav"; -@import "https://github.com/breadcrumb"; -@import "https://github.com/pagination"; -@import "https://github.com/tab"; -@import "https://github.com/slidenav"; // After: Icon -@import "https://github.com/dotnav"; -@import "https://github.com/thumbnav"; -@import "https://github.com/iconnav"; - -@import "https://github.com/lightbox"; // After: Close, Slidenav - -// Utilities -@import "https://github.com/animation"; -@import "https://github.com/width"; -@import "https://github.com/height"; -@import "https://github.com/text"; -@import "https://github.com/column"; -@import "https://github.com/cover"; -@import "https://github.com/background"; -@import "https://github.com/align"; -@import "https://github.com/svg"; -@import "https://github.com/utility"; -@import "https://github.com/flex"; // After: Utility -@import "https://github.com/margin"; -@import "https://github.com/padding"; -@import "https://github.com/position"; -@import "https://github.com/transition"; -@import "https://github.com/visibility"; -@import "https://github.com/inverse"; - -// Need to be loaded last -@import "https://github.com/print"; diff --git a/docs/_sass/uikit/components/_import.utilities.scss b/docs/_sass/uikit/components/_import.utilities.scss deleted file mode 100644 index 53712a56e7..0000000000 --- a/docs/_sass/uikit/components/_import.utilities.scss +++ /dev/null @@ -1,19 +0,0 @@ -// Utilities -@import "https://github.com/animation"; -@import "https://github.com/width"; -@import "https://github.com/text"; -@import "https://github.com/column"; -@import "https://github.com/cover"; -@import "https://github.com/background"; -@import "https://github.com/align"; -@import "https://github.com/utility"; -@import "https://github.com/flex"; // After: Utility -@import "https://github.com/margin"; -@import "https://github.com/padding"; -@import "https://github.com/position"; -@import "https://github.com/transition"; -@import "https://github.com/visibility"; -@import "https://github.com/inverse"; - -// Need to be loaded last -@import "https://github.com/print"; diff --git a/docs/_sass/uikit/components/_logo.scss b/docs/_sass/uikit/components/_logo.scss deleted file mode 100644 index 90875da1bc..0000000000 --- a/docs/_sass/uikit/components/_logo.scss +++ /dev/null @@ -1,8 +0,0 @@ -.logo { - font-size: 20px; - - a { - color: inherit; - text-decoration: none; - } -} diff --git a/docs/_sass/uikit/components/_main-menu.scss b/docs/_sass/uikit/components/_main-menu.scss deleted file mode 100644 index 457b9bbd0a..0000000000 --- a/docs/_sass/uikit/components/_main-menu.scss +++ /dev/null @@ -1,32 +0,0 @@ -.main-menu { - justify-content: flex-end; - - a { - color: inherit; - margin: 10px 20px; - text-decoration: none; - } - - button { - background-color: inherit; - border: none; - color: inherit; - margin: 0 20px 0 0; - } -} - -.dropdown-menu { - background-color: #fff; - color: #000; -} - -.dropdown-item { - font-size: 14px; - font-weight: 800; - padding: 10px 20px; - - &:hover { - background-color: inherit; - color: inherit; - } -} diff --git a/docs/_sass/uikit/components/_navigation-bar.scss b/docs/_sass/uikit/components/_navigation-bar.scss deleted file mode 100644 index 3cf2bdd76a..0000000000 --- a/docs/_sass/uikit/components/_navigation-bar.scss +++ /dev/null @@ -1,91 +0,0 @@ -.header { - border-bottom: 1px solid #E2E8F0; -} -.navbar { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 1.5rem; -} -#hamburger { - margin-bottom: 0; - display: none; -} -.bar { - display: block; - width: 25px; - height: 3px; - margin: 5px auto; - -webkit-transition: all 0.3s ease-in-out; - transition: all 0.3s ease-in-out; - background-color: #101010; -} -ul#nav-menu{ - list-style: none; - display: flex; - align-content: center; - justify-content: space-evenly; - margin-bottom:0; - li{ - padding: 1rem; - margin-bottom: 0; - a{ - text-decoration: none; - } - } - -} -#nav-item { - margin-left: 5rem; -} -.nav-link { - font-size: 1.6rem; - font-weight: 400; - color: #475569; - &:hover { - color: #482ff7; - } -} -.nav-logo { - font-size: 2.1rem; - font-weight: 500; - color: #482ff7; - padding: 1rem; -} -@media only screen and (max-width: 768px) { - #nav-menu { - position: fixed; - left: -100%; - top: 5rem; - flex-direction: column; - background-color: #fff; - width: 100%; - border-radius: 10px; - text-align: center; - transition: 0.3s; - box-shadow: 0 10px 27px rgba(0, 0, 0, 0.05); - } - #nav-menu.active { - left: 0; - } - #nav-item { - margin: 2.5rem 0; - } - #hamburger { - display: block; - cursor: pointer; - } - #hamburger.active { - .bar { - &:nth-child(2) { - opacity: 0; - } - &:nth-child(1) { - transform: translateY(8px) rotate(45deg); - } - &:nth-child(3) { - transform: translateY(-8px) rotate(-45deg); - } - } - } -} diff --git a/docs/_sass/uikit/components/_page.scss b/docs/_sass/uikit/components/_page.scss deleted file mode 100644 index 7559ad4178..0000000000 --- a/docs/_sass/uikit/components/_page.scss +++ /dev/null @@ -1,9 +0,0 @@ -.page { - background-color: #fff; - color: #333; - font-family: Open-sans, sans-serif; - font-size: 14px; - line-height: 20px; - margin: 0; - min-height: 100%; -} diff --git a/docs/_sass/uikit/components/_theme.scss b/docs/_sass/uikit/components/_theme.scss deleted file mode 100644 index f5a07ccc18..0000000000 --- a/docs/_sass/uikit/components/_theme.scss +++ /dev/null @@ -1,25 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600&display=swap'); - -/* colors */ -$primary: #e78a5fff; -$secondary: #b75065ff; -$info: #898ea3ff; -$dark: #433a51ff; -$light: #f5f5f5ff; /* applied fonts */ - - -html body { - font-family: 'Open Sans', sans-serif; - line-height: 1.5; - background-color: $primary; - -} - -/* sections spacing and colors */ -.section { - padding: 3rem 2rem; -} - -.dark-section { - background-color: $dark; -} \ No newline at end of file diff --git a/docs/_sass/uikit/components/_title.scss b/docs/_sass/uikit/components/_title.scss deleted file mode 100644 index 556a2526d2..0000000000 --- a/docs/_sass/uikit/components/_title.scss +++ /dev/null @@ -1,26 +0,0 @@ -.title-strip { - background-image: url("#{$baseurl}/assets/images/bg.png"); - background-size: cover; - color: #fff; - font-size: 25px; - font-weight: 500; - line-height: 30px; -} - -.title { - align-items: center; - justify-content: space-between; - max-width: 940px; - margin-left: auto; - margin-right: auto; - - h1 { - font-size: 60px; - font-weight: bold; - text-transform: uppercase; - max-height: 300px; - } - h3 { - line-height: 50px; - } -} diff --git a/docs/_sass/uikit/components/accordion.scss b/docs/_sass/uikit/components/accordion.scss deleted file mode 100644 index 477675469e..0000000000 --- a/docs/_sass/uikit/components/accordion.scss +++ /dev/null @@ -1,107 +0,0 @@ -// Name: Accordion -// Description: Component to create accordions -// -// Component: `uk-accordion` -// -// Sub-objects: `uk-accordion-title` -// `uk-accordion-content` -// -// States: `uk-open` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$accordion-item-margin-top: $global-margin !default; - -$accordion-title-font-size: $global-medium-font-size !default; -$accordion-title-line-height: 1.4 !default; -$accordion-title-color: $global-emphasis-color !default; -$accordion-title-hover-color: $global-color !default; - -$accordion-content-margin-top: $global-margin !default; - - -/* ======================================================================== - Component: Accordion - ========================================================================== */ - -.uk-accordion { - padding: 0; - list-style: none; - @if(mixin-exists(hook-accordion)) {@include hook-accordion();} -} - - -/* Item - ========================================================================== */ - -.uk-accordion > :nth-child(n+2) { - margin-top: $accordion-item-margin-top; - @if(mixin-exists(hook-accordion-item)) {@include hook-accordion-item();} -} - - -/* Title - ========================================================================== */ - -.uk-accordion-title { - display: block; - font-size: $accordion-title-font-size; - line-height: $accordion-title-line-height; - color: $accordion-title-color; - @if(mixin-exists(hook-accordion-title)) {@include hook-accordion-title();} -} - -/* Hover + Focus */ -.uk-accordion-title:hover, -.uk-accordion-title:focus { - color: $accordion-title-hover-color; - text-decoration: none; - outline: none; - @if(mixin-exists(hook-accordion-title-hover)) {@include hook-accordion-title-hover();} -} - - -/* Content - ========================================================================== */ - -.uk-accordion-content { - display: flow-root; - margin-top: $accordion-content-margin-top; - @if(mixin-exists(hook-accordion-content)) {@include hook-accordion-content();} -} - -/* - * Remove margin from the last-child - */ - - .uk-accordion-content > :last-child { margin-bottom: 0; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-accordion-misc)) {@include hook-accordion-misc();} - -// @mixin hook-accordion(){} -// @mixin hook-accordion-item(){} -// @mixin hook-accordion-title(){} -// @mixin hook-accordion-title-hover(){} -// @mixin hook-accordion-content(){} -// @mixin hook-accordion-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-accordion-title-color: $inverse-global-emphasis-color !default; -$inverse-accordion-title-hover-color: $inverse-global-color !default; - - - -// @mixin hook-inverse-accordion-item(){} -// @mixin hook-inverse-accordion-title(){} -// @mixin hook-inverse-accordion-title-hover(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/alert.scss b/docs/_sass/uikit/components/alert.scss deleted file mode 100644 index 236cc6787e..0000000000 --- a/docs/_sass/uikit/components/alert.scss +++ /dev/null @@ -1,147 +0,0 @@ -// Name: Alert -// Description: Component to create alert messages -// -// Component: `uk-alert` -// -// Adopted: `uk-alert-close` -// -// Modifiers: `uk-alert-primary` -// `uk-alert-success` -// `uk-alert-warning` -// `uk-alert-danger` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$alert-margin-vertical: $global-margin !default; -$alert-padding: 15px !default; -$alert-padding-right: $alert-padding + 14px !default; -$alert-background: $global-muted-background !default; -$alert-color: $global-color !default; - -$alert-close-top: $alert-padding + 5px !default; -$alert-close-right: $alert-padding !default; - -$alert-primary-background: lighten(mix(white, $global-primary-background, 40%), 20%) !default; -$alert-primary-color: $global-primary-background !default; - -$alert-success-background: lighten(mix(white, $global-success-background, 40%), 25%) !default; -$alert-success-color: $global-success-background !default; - -$alert-warning-background: lighten(mix(white, $global-warning-background, 45%), 15%) !default; -$alert-warning-color: $global-warning-background !default; - -$alert-danger-background: lighten(mix(white, $global-danger-background, 40%), 20%) !default; -$alert-danger-color: $global-danger-background !default; - - -/* ======================================================================== - Component: Alert - ========================================================================== */ - -.uk-alert { - position: relative; - margin-bottom: $alert-margin-vertical; - padding: $alert-padding $alert-padding-right $alert-padding $alert-padding; - background: $alert-background; - color: $alert-color; - @if(mixin-exists(hook-alert)) {@include hook-alert();} -} - -/* Add margin if adjacent element */ -* + .uk-alert { margin-top: $alert-margin-vertical; } - -/* - * Remove margin from the last-child - */ - -.uk-alert > :last-child { margin-bottom: 0; } - - -/* Close - * Adopts `uk-close` - ========================================================================== */ - -.uk-alert-close { - position: absolute; - top: $alert-close-top; - right: $alert-close-right; - @if(mixin-exists(hook-alert-close)) {@include hook-alert-close();} -} - -/* - * Remove margin from adjacent element - */ - -.uk-alert-close:first-child + * { margin-top: 0; } - -/* - * Hover + Focus - */ - -.uk-alert-close:hover, -.uk-alert-close:focus { - @if(mixin-exists(hook-alert-close-hover)) {@include hook-alert-close-hover();} -} - - -/* Style modifiers - ========================================================================== */ - -/* - * Primary - */ - -.uk-alert-primary { - background: $alert-primary-background; - color: $alert-primary-color; - @if(mixin-exists(hook-alert-primary)) {@include hook-alert-primary();} -} - -/* - * Success - */ - -.uk-alert-success { - background: $alert-success-background; - color: $alert-success-color; - @if(mixin-exists(hook-alert-success)) {@include hook-alert-success();} -} - -/* - * Warning - */ - -.uk-alert-warning { - background: $alert-warning-background; - color: $alert-warning-color; - @if(mixin-exists(hook-alert-warning)) {@include hook-alert-warning();} -} - -/* - * Danger - */ - -.uk-alert-danger { - background: $alert-danger-background; - color: $alert-danger-color; - @if(mixin-exists(hook-alert-danger)) {@include hook-alert-danger();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-alert-misc)) {@include hook-alert-misc();} - -// @mixin hook-alert(){} -// @mixin hook-alert-close(){} -// @mixin hook-alert-close-hover(){} -// @mixin hook-alert-primary(){} -// @mixin hook-alert-success(){} -// @mixin hook-alert-warning(){} -// @mixin hook-alert-danger(){} -// @mixin hook-alert-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/align.scss b/docs/_sass/uikit/components/align.scss deleted file mode 100644 index bee6702bca..0000000000 --- a/docs/_sass/uikit/components/align.scss +++ /dev/null @@ -1,142 +0,0 @@ -// Name: Align -// Description: Utilities to align embedded content -// -// Component: `uk-align-left-*` -// `uk-align-right-*` -// `uk-align-center` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$align-margin-horizontal: $global-gutter !default; -$align-margin-vertical: $global-gutter !default; - -$align-margin-horizontal-l: $global-medium-gutter !default; - - -/* ======================================================================== - Component: Align - ========================================================================== */ - -/* - * Default - */ - -[class*='uk-align'] { - display: block; - margin-bottom: $align-margin-vertical; -} - -* + [class*='uk-align'] { margin-top: $align-margin-vertical; } - -/* - * Center - */ - -.uk-align-center { - margin-left: auto; - margin-right: auto; -} - -/* - * Left/Right - */ - -.uk-align-left { - margin-top: 0; - margin-right: $align-margin-horizontal; - float: left; -} - -.uk-align-right { - margin-top: 0; - margin-left: $align-margin-horizontal; - float: right; -} - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-align-left\@s { - margin-top: 0; - margin-right: $align-margin-horizontal; - float: left; - } - - .uk-align-right\@s { - margin-top: 0; - margin-left: $align-margin-horizontal; - float: right; - } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-align-left\@m { - margin-top: 0; - margin-right: $align-margin-horizontal; - float: left; - } - - .uk-align-right\@m { - margin-top: 0; - margin-left: $align-margin-horizontal; - float: right; - } - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-align-left\@l { - margin-top: 0; - float: left; - } - - .uk-align-right\@l { - margin-top: 0; - float: right; - } - - .uk-align-left, - .uk-align-left\@s, - .uk-align-left\@m, - .uk-align-left\@l { margin-right: $align-margin-horizontal-l; } - - .uk-align-right, - .uk-align-right\@s, - .uk-align-right\@m, - .uk-align-right\@l { margin-left: $align-margin-horizontal-l; } - -} - -/* Large screen and bigger */ -@media (min-width: $breakpoint-xlarge) { - - .uk-align-left\@xl { - margin-top: 0; - margin-right: $align-margin-horizontal-l; - float: left; - } - - .uk-align-right\@xl { - margin-top: 0; - margin-left: $align-margin-horizontal-l; - float: right; - } - -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-align-misc)) {@include hook-align-misc();} - -// @mixin hook-align-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/animation.scss b/docs/_sass/uikit/components/animation.scss deleted file mode 100644 index c955238499..0000000000 --- a/docs/_sass/uikit/components/animation.scss +++ /dev/null @@ -1,430 +0,0 @@ -// Name: Animation -// Description: Utilities for keyframe animations -// -// Component: `uk-animation-*` -// -// Modifiers: `uk-animation-fade` -// `uk-animation-scale-up` -// `uk-animation-scale-down` -// `uk-animation-slide-top-*` -// `uk-animation-slide-bottom-*` -// `uk-animation-slide-left-*` -// `uk-animation-slide-right-*` -// `uk-animation-kenburns` -// `uk-animation-shake` -// `uk-animation-stroke` -// `uk-animation-reverse` -// `uk-animation-fast` -// -// Sub-objects: `uk-animation-toggle` -// -// States: `uk-active` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$animation-duration: 0.5s !default; -$animation-fade-duration: 0.8s !default; -$animation-stroke-duration: 2s !default; -$animation-kenburns-duration: 15s !default; -$animation-fast-duration: 0.1s !default; - -$animation-slide-small-translate: 10px !default; -$animation-slide-medium-translate: 50px !default; - - -/* ======================================================================== - Component: Animation - ========================================================================== */ - -[class*='uk-animation-'] { - animation-duration: $animation-duration; - animation-timing-function: ease-out; - animation-fill-mode: both; -} - - -/* Animations - ========================================================================== */ - -/* - * Fade - */ - -.uk-animation-fade { - animation-name: uk-fade; - animation-duration: $animation-fade-duration; - animation-timing-function: linear; -} - -/* - * Scale - */ - -.uk-animation-scale-up { animation-name: uk-fade-scale-02; } -.uk-animation-scale-down { animation-name: uk-fade-scale-18; } - -/* - * Slide - */ - -.uk-animation-slide-top { animation-name: uk-fade-top; } -.uk-animation-slide-bottom { animation-name: uk-fade-bottom; } -.uk-animation-slide-left { animation-name: uk-fade-left; } -.uk-animation-slide-right { animation-name: uk-fade-right; } - -/* - * Slide Small - */ - -.uk-animation-slide-top-small { animation-name: uk-fade-top-small; } -.uk-animation-slide-bottom-small { animation-name: uk-fade-bottom-small; } -.uk-animation-slide-left-small { animation-name: uk-fade-left-small; } -.uk-animation-slide-right-small { animation-name: uk-fade-right-small; } - -/* - * Slide Medium - */ - -.uk-animation-slide-top-medium { animation-name: uk-fade-top-medium; } -.uk-animation-slide-bottom-medium { animation-name: uk-fade-bottom-medium; } -.uk-animation-slide-left-medium { animation-name: uk-fade-left-medium; } -.uk-animation-slide-right-medium { animation-name: uk-fade-right-medium; } - -/* - * Kenburns - */ - -.uk-animation-kenburns { - animation-name: uk-scale-kenburns; - animation-duration: $animation-kenburns-duration; -} - -/* - * Shake - */ - -.uk-animation-shake { animation-name: uk-shake; } - -/* - * SVG Stroke - * The `--uk-animation-stroke` custom property contains the longest path length. - * Set it manually or use `uk-svg="stroke-animation: true"` to set it automatically. - * All strokes are animated by the same pace and doesn't end simultaneously. - * To end simultaneously, `pathLength="1"` could be used, but it's not working in Safari yet. - */ - -.uk-animation-stroke { - animation-name: uk-stroke; - stroke-dasharray: var(--uk-animation-stroke); - animation-duration: $animation-stroke-duration; -} - - -/* Direction modifier - ========================================================================== */ - - .uk-animation-reverse { - animation-direction: reverse; - animation-timing-function: ease-in; -} - - -/* Duration modifier - ========================================================================== */ - - .uk-animation-fast { animation-duration: $animation-fast-duration; } - - -/* Toggle (Hover + Focus) -========================================================================== */ - -/* - * The toggle is triggered on touch devices using `:focus` and tabindex - */ - -.uk-animation-toggle:not(:hover):not(:focus) [class*='uk-animation-'] { animation-name: none; } - -/* - * 1. Prevent tab highlighting on iOS. - */ - -.uk-animation-toggle { - /* 1 */ - -webkit-tap-highlight-color: transparent; -} - -/* - * Remove outline for `tabindex` - */ - -.uk-animation-toggle:focus { outline: none; } - - -/* Keyframes used by animation classes - ========================================================================== */ - -/* - * Fade - */ - -@keyframes uk-fade { - 0% { opacity: 0; } - 100% { opacity: 1; } -} - -/* - * Slide Top - */ - -@keyframes uk-fade-top { - 0% { - opacity: 0; - transform: translateY(-100%); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -/* - * Slide Bottom - */ - -@keyframes uk-fade-bottom { - 0% { - opacity: 0; - transform: translateY(100%); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -/* - * Slide Left - */ - -@keyframes uk-fade-left { - 0% { - opacity: 0; - transform: translateX(-100%); - } - 100% { - opacity: 1; - transform: translateX(0); - } -} - -/* - * Slide Right - */ - -@keyframes uk-fade-right { - 0% { - opacity: 0; - transform: translateX(100%); - } - 100% { - opacity: 1; - transform: translateX(0); - } -} - -/* - * Slide Top Small - */ - -@keyframes uk-fade-top-small { - 0% { - opacity: 0; - transform: translateY(-$animation-slide-small-translate); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -/* - * Slide Bottom Small - */ - -@keyframes uk-fade-bottom-small { - 0% { - opacity: 0; - transform: translateY($animation-slide-small-translate); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -/* - * Slide Left Small - */ - -@keyframes uk-fade-left-small { - 0% { - opacity: 0; - transform: translateX(-$animation-slide-small-translate); - } - 100% { - opacity: 1; - transform: translateX(0); - } -} - -/* - * Slide Right Small - */ - -@keyframes uk-fade-right-small { - 0% { - opacity: 0; - transform: translateX($animation-slide-small-translate); - } - 100% { - opacity: 1; - transform: translateX(0); - } -} - -/* - * Slide Top Medium - */ - -@keyframes uk-fade-top-medium { - 0% { - opacity: 0; - transform: translateY(-$animation-slide-medium-translate); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -/* - * Slide Bottom Medium - */ - -@keyframes uk-fade-bottom-medium { - 0% { - opacity: 0; - transform: translateY($animation-slide-medium-translate); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -/* - * Slide Left Medium - */ - -@keyframes uk-fade-left-medium { - 0% { - opacity: 0; - transform: translateX(-$animation-slide-medium-translate); - } - 100% { - opacity: 1; - transform: translateX(0); - } -} - -/* - * Slide Right Medium - */ - -@keyframes uk-fade-right-medium { - 0% { - opacity: 0; - transform: translateX($animation-slide-medium-translate); - } - 100% { - opacity: 1; - transform: translateX(0); - } -} - -/* - * Scale Up - */ - -@keyframes uk-fade-scale-02 { - 0% { - opacity: 0; - transform: scale(0.2); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - -/* - * Scale Down - */ - -@keyframes uk-fade-scale-18 { - 0% { - opacity: 0; - transform: scale(1.8); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - -/* - * Kenburns - */ - -@keyframes uk-scale-kenburns { - 0% { transform: scale(1); } - 100% { transform: scale(1.2); } -} - -/* - * Shake - */ - -@keyframes uk-shake { - 0%, 100% { transform: translateX(0); } - 10% { transform: translateX(-9px); } - 20% { transform: translateX(8px); } - 30% { transform: translateX(-7px); } - 40% { transform: translateX(6px); } - 50% { transform: translateX(-5px); } - 60% { transform: translateX(4px); } - 70% { transform: translateX(-3px); } - 80% { transform: translateX(2px); } - 90% { transform: translateX(-1px); } -} - -/* - * Stroke - */ - - @keyframes uk-stroke { - 0% { stroke-dashoffset: var(--uk-animation-stroke); } - 100% { stroke-dashoffset: 0; } -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-animation-misc)) {@include hook-animation-misc();} - -// @mixin hook-animation-misc(){} diff --git a/docs/_sass/uikit/components/article.scss b/docs/_sass/uikit/components/article.scss deleted file mode 100644 index 4fa4e2b2d0..0000000000 --- a/docs/_sass/uikit/components/article.scss +++ /dev/null @@ -1,99 +0,0 @@ -// Name: Article -// Description: Component to create articles -// -// Component: `uk-article` -// -// Sub-objects: `uk-article-title` -// `uk-article-meta` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$article-margin-top: $global-large-margin !default; - -$article-title-font-size-m: $global-2xlarge-font-size !default; -$article-title-font-size: $article-title-font-size-m * 0.85 !default; -$article-title-line-height: 1.2 !default; - -$article-meta-font-size: $global-small-font-size !default; -$article-meta-line-height: 1.4 !default; -$article-meta-color: $global-muted-color !default; - - -/* ======================================================================== - Component: Article - ========================================================================== */ - -.uk-article { - display: flow-root; - @if(mixin-exists(hook-article)) {@include hook-article();} -} - -/* - * Remove margin from the last-child - */ - -.uk-article > :last-child { margin-bottom: 0; } - - -/* Adjacent sibling - ========================================================================== */ - -.uk-article + .uk-article { - margin-top: $article-margin-top; - @if(mixin-exists(hook-article-adjacent)) {@include hook-article-adjacent();} -} - - -/* Title - ========================================================================== */ - -.uk-article-title { - font-size: $article-title-font-size; - line-height: $article-title-line-height; - @if(mixin-exists(hook-article-title)) {@include hook-article-title();} -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-article-title { font-size: $article-title-font-size-m; } - -} - - -/* Meta - ========================================================================== */ - -.uk-article-meta { - font-size: $article-meta-font-size; - line-height: $article-meta-line-height; - color: $article-meta-color; - @if(mixin-exists(hook-article-meta)) {@include hook-article-meta();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-article-misc)) {@include hook-article-misc();} - -// @mixin hook-article(){} -// @mixin hook-article-adjacent(){} -// @mixin hook-article-title(){} -// @mixin hook-article-meta(){} -// @mixin hook-article-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-article-meta-color: $inverse-global-muted-color !default; - - - -// @mixin hook-inverse-article-title(){} -// @mixin hook-inverse-article-meta(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/background.scss b/docs/_sass/uikit/components/background.scss deleted file mode 100644 index d486672b59..0000000000 --- a/docs/_sass/uikit/components/background.scss +++ /dev/null @@ -1,148 +0,0 @@ -// Name: Background -// Description: Utilities for backgrounds -// -// Component: `uk-background-*` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$background-default-background: $global-background !default; -$background-muted-background: $global-muted-background !default; -$background-primary-background: $global-primary-background !default; -$background-secondary-background: $global-secondary-background !default; - - -/* ======================================================================== - Component: Background - ========================================================================== */ - - -/* Color - ========================================================================== */ - -.uk-background-default { background-color: $background-default-background; } -.uk-background-muted { background-color: $background-muted-background; } -.uk-background-primary { background-color: $background-primary-background; } -.uk-background-secondary { background-color: $background-secondary-background; } - - -/* Size - ========================================================================== */ - -.uk-background-cover, -.uk-background-contain, -.uk-background-width-1-1, -.uk-background-height-1-1 { - background-position: 50% 50%; - background-repeat: no-repeat; -} - -.uk-background-cover { background-size: cover; } -.uk-background-contain { background-size: contain; } -.uk-background-width-1-1 { background-size: 100%; } -.uk-background-height-1-1 { background-size: auto 100%; } - - -/* Position - ========================================================================== */ - -.uk-background-top-left { background-position: 0 0; } -.uk-background-top-center { background-position: 50% 0; } -.uk-background-top-right { background-position: 100% 0; } -.uk-background-center-left { background-position: 0 50%; } -.uk-background-center-center { background-position: 50% 50%; } -.uk-background-center-right { background-position: 100% 50%; } -.uk-background-bottom-left { background-position: 0 100%; } -.uk-background-bottom-center { background-position: 50% 100%; } -.uk-background-bottom-right { background-position: 100% 100%; } - - -/* Repeat - ========================================================================== */ - -.uk-background-norepeat { background-repeat: no-repeat; } - - -/* Attachment - ========================================================================== */ - -/* - * 1. Fix bug introduced in Chrome 67: the background image is not visible if any element on the page uses `translate3d` - */ - -.uk-background-fixed { - background-attachment: fixed; - /* 1 */ - backface-visibility: hidden; -} - -/* - * Exclude touch devices because `fixed` doesn't work on iOS and Android - */ - -@media (pointer: coarse) { - .uk-background-fixed { background-attachment: scroll; } -} - - -/* Image - ========================================================================== */ - -/* Phone portrait and smaller */ -@media (max-width: $breakpoint-xsmall-max) { - - .uk-background-image\@s { background-image: none !important; } - -} - -/* Phone landscape and smaller */ -@media (max-width: $breakpoint-small-max) { - - .uk-background-image\@m { background-image: none !important; } - -} - -/* Tablet landscape and smaller */ -@media (max-width: $breakpoint-medium-max) { - - .uk-background-image\@l { background-image: none !important; } - -} - -/* Desktop and smaller */ -@media (max-width: $breakpoint-large-max) { - - .uk-background-image\@xl {background-image: none !important; } - -} - - -/* Blend modes - ========================================================================== */ - -.uk-background-blend-multiply { background-blend-mode: multiply; } -.uk-background-blend-screen { background-blend-mode: screen; } -.uk-background-blend-overlay { background-blend-mode: overlay; } -.uk-background-blend-darken { background-blend-mode: darken; } -.uk-background-blend-lighten { background-blend-mode: lighten; } -.uk-background-blend-color-dodge { background-blend-mode: color-dodge; } -.uk-background-blend-color-burn { background-blend-mode: color-burn; } -.uk-background-blend-hard-light { background-blend-mode: hard-light; } -.uk-background-blend-soft-light { background-blend-mode: soft-light; } -.uk-background-blend-difference { background-blend-mode: difference; } -.uk-background-blend-exclusion { background-blend-mode: exclusion; } -.uk-background-blend-hue { background-blend-mode: hue; } -.uk-background-blend-saturation { background-blend-mode: saturation; } -.uk-background-blend-color { background-blend-mode: color; } -.uk-background-blend-luminosity { background-blend-mode: luminosity; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-background-misc)) {@include hook-background-misc();} - -// @mixin hook-background-misc(){} diff --git a/docs/_sass/uikit/components/badge.scss b/docs/_sass/uikit/components/badge.scss deleted file mode 100644 index 1df2236bf9..0000000000 --- a/docs/_sass/uikit/components/badge.scss +++ /dev/null @@ -1,80 +0,0 @@ -// Name: Badge -// Description: Component to create notification badges -// -// Component: `uk-badge` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$badge-size: 18px !default; -$badge-padding-vertical: 0 !default; -$badge-padding-horizontal: 5px !default; -$badge-border-radius: 500px !default; -$badge-background: $global-primary-background !default; -$badge-color: $global-inverse-color !default; -$badge-font-size: 11px !default; - - -/* ======================================================================== - Component: Badge - ========================================================================== */ - -/* - * 1. Style - * 2. Center child vertically and horizontally - */ - -.uk-badge { - box-sizing: border-box; - min-width: $badge-size; - height: $badge-size; - padding: $badge-padding-vertical $badge-padding-horizontal; - border-radius: $badge-border-radius; - vertical-align: middle; - /* 1 */ - background: $badge-background; - color: $badge-color !important; - font-size: $badge-font-size; - /* 2 */ - display: inline-flex; - justify-content: center; - align-items: center; - line-height: 0; - @if(mixin-exists(hook-badge)) {@include hook-badge();} -} - -/* - * Required for `a` - */ - -.uk-badge:hover, -.uk-badge:focus { - text-decoration: none; - outline: none; - @if(mixin-exists(hook-badge-hover)) {@include hook-badge-hover();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-badge-misc)) {@include hook-badge-misc();} - -// @mixin hook-badge(){} -// @mixin hook-badge-hover(){} -// @mixin hook-badge-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-badge-background: $inverse-global-primary-background !default; -$inverse-badge-color: $inverse-global-inverse-color !default; - - - -// @mixin hook-inverse-badge(){} -// @mixin hook-inverse-badge-hover(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/base.scss b/docs/_sass/uikit/components/base.scss deleted file mode 100644 index 188af873fd..0000000000 --- a/docs/_sass/uikit/components/base.scss +++ /dev/null @@ -1,628 +0,0 @@ -// Name: Base -// Description: Default values for HTML elements -// -// Component: `uk-link` -// `uk-h1`, `uk-h2`, `uk-h3`, `uk-h4`, `uk-h5`, `uk-h6` -// `uk-hr` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$base-body-background: $global-background !default; -$base-body-font-family: $global-font-family !default; -$base-body-font-weight: normal !default; -$base-body-font-size: $global-font-size !default; -$base-body-line-height: $global-line-height !default; -$base-body-color: $global-color !default; - -$base-link-color: $global-link-color !default; -$base-link-text-decoration: none !default; -$base-link-hover-color: $global-link-hover-color !default; -$base-link-hover-text-decoration: underline !default; - -$base-strong-font-weight: bolder !default; -$base-code-font-size: $global-small-font-size !default; -$base-code-font-family: Consolas, monaco, monospace !default; -$base-code-color: $global-danger-background !default; -$base-em-color: $global-danger-background !default; -$base-ins-background: #ffd !default; -$base-ins-color: $global-color !default; -$base-mark-background: #ffd !default; -$base-mark-color: $global-color !default; -$base-quote-font-style: italic !default; -$base-small-font-size: 80% !default; - -$base-margin-vertical: $global-margin !default; - -$base-heading-font-family: $global-font-family !default; -$base-heading-font-weight: normal !default; -$base-heading-color: $global-emphasis-color !default; -$base-heading-text-transform: none !default; -$base-heading-margin-top: $global-medium-margin !default; -$base-h1-font-size-m: $global-2xlarge-font-size !default; -$base-h1-font-size: $base-h1-font-size-m * 0.85 !default; -$base-h1-line-height: 1.2 !default; -$base-h2-font-size-m: $global-xlarge-font-size !default; -$base-h2-font-size: $base-h2-font-size-m * 0.85 !default; -$base-h2-line-height: 1.3 !default; -$base-h3-font-size: $global-large-font-size !default; -$base-h3-line-height: 1.4 !default; -$base-h4-font-size: $global-medium-font-size !default; -$base-h4-line-height: 1.4 !default; -$base-h5-font-size: $global-font-size !default; -$base-h5-line-height: 1.4 !default; -$base-h6-font-size: $global-small-font-size !default; -$base-h6-line-height: 1.4 !default; - -$base-list-padding-left: 30px !default; - -$base-hr-margin-vertical: $global-margin !default; -$base-hr-border-width: $global-border-width !default; -$base-hr-border: $global-border !default; - -$base-blockquote-font-size: $global-medium-font-size !default; -$base-blockquote-line-height: 1.5 !default; -$base-blockquote-font-style: italic !default; -$base-blockquote-margin-vertical: $global-margin !default; -$base-blockquote-footer-margin-top: $global-small-margin !default; -$base-blockquote-footer-font-size: $global-small-font-size !default; -$base-blockquote-footer-line-height: 1.5 !default; - -$base-pre-font-size: $global-small-font-size !default; -$base-pre-line-height: 1.5 !default; -$base-pre-font-family: $base-code-font-family !default; -$base-pre-color: $global-color !default; - -$base-selection-background: #39f !default; -$base-selection-color: $global-inverse-color !default; - - -/* ======================================================================== - Component: Base - ========================================================================== */ - -/* - * 1. Set `font-size` to support `rem` units - * Not using `font` property because a leading hyphen (e.g. -apple-system) causes the font to break in IE11 and Edge - * 2. Prevent adjustments of font size after orientation changes in iOS. - * 3. Style - */ - -html { - /* 1 */ - font-family: $base-body-font-family; - font-size: $base-body-font-size; - font-weight: $base-body-font-weight; - line-height: $base-body-line-height; - /* 2 */ - -webkit-text-size-adjust: 100%; - /* 3 */ - background: $base-body-background; - color: $base-body-color; - @if(mixin-exists(hook-base-body)) {@include hook-base-body();} -} - -/* - * Remove the margin in all browsers. - */ - -body { margin: 0; } - - -/* Links - ========================================================================== */ - -/* - * Remove the outline on focused links when they are also active or hovered - */ - -a:active, -a:hover { outline: none; } - -/* - * Style - */ - -a, -.uk-link { - color: $base-link-color; - text-decoration: $base-link-text-decoration; - cursor: pointer; - @if(mixin-exists(hook-base-link)) {@include hook-base-link();} -} - -a:hover, -.uk-link:hover, -.uk-link-toggle:hover .uk-link, -.uk-link-toggle:focus .uk-link { - color: $base-link-hover-color; - text-decoration: $base-link-hover-text-decoration; - @if(mixin-exists(hook-base-link-hover)) {@include hook-base-link-hover();} -} - - -/* Text-level semantics - ========================================================================== */ - -/* - * 1. Add the correct text decoration in Edge. - * 2. The shorthand declaration `underline dotted` is not supported in Safari. - */ - -abbr[title] { - /* 1 */ - text-decoration: underline dotted; - /* 2 */ - -webkit-text-decoration-style: dotted; -} - -/* - * Add the correct font weight in Chrome, Edge, and Safari. - */ - -b, -strong { font-weight: $base-strong-font-weight; } - -/* - * 1. Consolas has a better baseline in running text compared to `Courier` - * 2. Correct the odd `em` font sizing in all browsers. - * 3. Style - */ - -:not(pre) > code, -:not(pre) > kbd, -:not(pre) > samp { - /* 1 */ - font-family: $base-code-font-family; - /* 2 */ - font-size: $base-code-font-size; - /* 3 */ - color: $base-code-color; - white-space: nowrap; - @if(mixin-exists(hook-base-code)) {@include hook-base-code();} -} - -/* - * Emphasize - */ - -em { color: $base-em-color; } - -/* - * Insert - */ - -ins { - background: $base-ins-background; - color: $base-ins-color; - text-decoration: none; -} - -/* - * Mark - */ - -mark { - background: $base-mark-background; - color: $base-mark-color; -} - -/* - * Quote - */ - -q { font-style: $base-quote-font-style; } - -/* - * Add the correct font size in all browsers. - */ - -small { font-size: $base-small-font-size; } - -/* - * Prevents `sub` and `sup` affecting `line-height` in all browsers. - */ - -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -sup { top: -0.5em; } -sub { bottom: -0.25em; } - - -/* Embedded content - ========================================================================== */ - -/* - * Remove the gap between embedded content and the bottom of their containers. - */ - -audio, -canvas, -iframe, -img, -svg, -video { vertical-align: middle; } - -/* - * 1. Add responsiveness. - * 2. Auto-scale the height. Only needed if `height` attribute is present. - * 3. Corrects responsive `max-width` behavior if padding and border are used. - * 4. Exclude SVGs for IE11 because they don't preserve their aspect ratio. - */ - -canvas, -img, -video { - /* 1 */ - max-width: 100%; - /* 2 */ - height: auto; - /* 3 */ - box-sizing: border-box; -} - -/* 4 */ -@supports (display: block) { - - svg { - max-width: 100%; - height: auto; - box-sizing: border-box; - } - -} - -/* - * Hide the overflow in IE. - */ - -svg:not(:root) { overflow: hidden; } - -/* - * 1. Fix lazy loading images if parent element is set to `display: inline` and has `overflow: hidden`. - * 2. Hide `alt` text for lazy loading images. - * Note: Selector for background while loading img[data-src*='.jpg'][src*='data:image'] { background: grey; } - */ - -img:not([src]) { - /* 1 */ - min-width: 1px; - /* 2 */ - visibility: hidden; -} - -/* - * Iframe - * Remove border in all browsers - */ - -iframe { border: 0; } - - -/* Block elements - ========================================================================== */ - -/* - * Margins - */ - -p, -ul, -ol, -dl, -pre, -address, -fieldset, -figure { margin: 0 0 $base-margin-vertical 0; } - -/* Add margin if adjacent element */ -* + p, -* + ul, -* + ol, -* + dl, -* + pre, -* + address, -* + fieldset, -* + figure { margin-top: $base-margin-vertical; } - - -/* Headings - ========================================================================== */ - -h1, .uk-h1, -h2, .uk-h2, -h3, .uk-h3, -h4, .uk-h4, -h5, .uk-h5, -h6, .uk-h6, -.uk-heading-small, -.uk-heading-medium, -.uk-heading-large, -.uk-heading-xlarge, -.uk-heading-2xlarge { - margin: 0 0 $base-margin-vertical 0; - font-family: $base-heading-font-family; - font-weight: $base-heading-font-weight; - color: $base-heading-color; - text-transform: $base-heading-text-transform; - @if(mixin-exists(hook-base-heading)) {@include hook-base-heading();} -} - -/* Add margin if adjacent element */ -* + h1, * + .uk-h1, -* + h2, * + .uk-h2, -* + h3, * + .uk-h3, -* + h4, * + .uk-h4, -* + h5, * + .uk-h5, -* + h6, * + .uk-h6, -* + .uk-heading-small, -* + .uk-heading-medium, -* + .uk-heading-large, -* + .uk-heading-xlarge, -* + .uk-heading-2xlarge { margin-top: $base-heading-margin-top; } - -/* - * Sizes - */ - -h1, .uk-h1 { - font-size: $base-h1-font-size; - line-height: $base-h1-line-height; - @if(mixin-exists(hook-base-h1)) {@include hook-base-h1();} -} - -h2, .uk-h2 { - font-size: $base-h2-font-size; - line-height: $base-h2-line-height; - @if(mixin-exists(hook-base-h2)) {@include hook-base-h2();} -} - -h3, .uk-h3 { - font-size: $base-h3-font-size; - line-height: $base-h3-line-height; - @if(mixin-exists(hook-base-h3)) {@include hook-base-h3();} -} - -h4, .uk-h4 { - font-size: $base-h4-font-size; - line-height: $base-h4-line-height; - @if(mixin-exists(hook-base-h4)) {@include hook-base-h4();} -} - -h5, .uk-h5 { - font-size: $base-h5-font-size; - line-height: $base-h5-line-height; - @if(mixin-exists(hook-base-h5)) {@include hook-base-h5();} -} - -h6, .uk-h6 { - font-size: $base-h6-font-size; - line-height: $base-h6-line-height; - @if(mixin-exists(hook-base-h6)) {@include hook-base-h6();} -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - h1, .uk-h1 { font-size: $base-h1-font-size-m; } - h2, .uk-h2 { font-size: $base-h2-font-size-m; } - -} - - -/* Lists - ========================================================================== */ - -ul, -ol { padding-left: $base-list-padding-left; } - -/* - * Reset margin for nested lists - */ - -ul > li > ul, -ul > li > ol, -ol > li > ol, -ol > li > ul { margin: 0; } - - -/* Description lists - ========================================================================== */ - -dt { font-weight: bold; } -dd { margin-left: 0; } - - -/* Horizontal rules - ========================================================================== */ - -/* - * 1. Show the overflow in Chrome, Edge and IE. - * 2. Add the correct text-align in Edge and IE. - * 3. Style - */ - -hr, .uk-hr { - /* 1 */ - overflow: visible; - /* 2 */ - text-align: inherit; - /* 3 */ - margin: 0 0 $base-hr-margin-vertical 0; - border: 0; - border-top: $base-hr-border-width solid $base-hr-border; - @if(mixin-exists(hook-base-hr)) {@include hook-base-hr();} -} - -/* Add margin if adjacent element */ -* + hr, -* + .uk-hr { margin-top: $base-hr-margin-vertical } - - -/* Address - ========================================================================== */ - -address { font-style: normal; } - - -/* Blockquotes - ========================================================================== */ - -blockquote { - margin: 0 0 $base-blockquote-margin-vertical 0; - font-size: $base-blockquote-font-size; - line-height: $base-blockquote-line-height; - font-style: $base-blockquote-font-style; - @if(mixin-exists(hook-base-blockquote)) {@include hook-base-blockquote();} -} - -/* Add margin if adjacent element */ -* + blockquote { margin-top: $base-blockquote-margin-vertical; } - -/* - * Content - */ - -blockquote p:last-of-type { margin-bottom: 0; } - -blockquote footer { - margin-top: $base-blockquote-footer-margin-top; - font-size: $base-blockquote-footer-font-size; - line-height: $base-blockquote-footer-line-height; - @if(mixin-exists(hook-base-blockquote-footer)) {@include hook-base-blockquote-footer();} -} - - -/* Preformatted text - ========================================================================== */ - -/* - * 1. Contain overflow in all browsers. - */ - -pre { - font: $base-pre-font-size unquote("/") $base-pre-line-height $base-pre-font-family; - color: $base-pre-color; - -moz-tab-size: 4; - tab-size: 4; - /* 1 */ - overflow: auto; - @if(mixin-exists(hook-base-pre)) {@include hook-base-pre();} -} - -pre code { font-family: $base-pre-font-family; } - - -/* Selection pseudo-element - ========================================================================== */ - -::selection { - background: $base-selection-background; - color: $base-selection-color; - text-shadow: none; -} - - -/* HTML5 elements - ========================================================================== */ - -/* - * 1. Add the correct display in Edge, IE 10+, and Firefox. - * 2. Add the correct display in IE. - */ - -details, /* 1 */ -main { /* 2 */ - display: block; -} - -/* - * Add the correct display in all browsers. - */ - -summary { display: list-item; } - -/* - * Add the correct display in IE. - */ - -template { display: none; } - - -/* Pass media breakpoints to JS - ========================================================================== */ - -/* - * Breakpoints - */ - -.uk-breakpoint-s::before { content: '#{$breakpoint-small}'; } -.uk-breakpoint-m::before { content: '#{$breakpoint-medium}'; } -.uk-breakpoint-l::before { content: '#{$breakpoint-large}'; } -.uk-breakpoint-xl::before { content: '#{$breakpoint-xlarge}'; } - -:root { - --uk-breakpoint-s: #{$breakpoint-small}; - --uk-breakpoint-m: #{$breakpoint-medium}; - --uk-breakpoint-l: #{$breakpoint-large}; - --uk-breakpoint-xl: #{$breakpoint-xlarge}; -} - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-base-misc)) {@include hook-base-misc();} - -// @mixin hook-base-body(){} -// @mixin hook-base-link(){} -// @mixin hook-base-link-hover(){} -// @mixin hook-base-code(){} -// @mixin hook-base-heading(){} -// @mixin hook-base-h1(){} -// @mixin hook-base-h2(){} -// @mixin hook-base-h3(){} -// @mixin hook-base-h4(){} -// @mixin hook-base-h5(){} -// @mixin hook-base-h6(){} -// @mixin hook-base-hr(){} -// @mixin hook-base-blockquote(){} -// @mixin hook-base-blockquote-footer(){} -// @mixin hook-base-pre(){} -// @mixin hook-base-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-base-color: $inverse-global-color !default; -$inverse-base-link-color: $inverse-global-emphasis-color !default; -$inverse-base-link-hover-color: $inverse-global-emphasis-color !default; -$inverse-base-code-color: $inverse-global-color !default; -$inverse-base-em-color: $inverse-global-emphasis-color !default; -$inverse-base-heading-color: $inverse-global-emphasis-color !default; -$inverse-base-hr-border: $inverse-global-border !default; - - - -// @mixin hook-inverse-base-link(){} -// @mixin hook-inverse-base-link-hover(){} -// @mixin hook-inverse-base-code(){} -// @mixin hook-inverse-base-heading(){} -// @mixin hook-inverse-base-h1(){} -// @mixin hook-inverse-base-h2(){} -// @mixin hook-inverse-base-h3(){} -// @mixin hook-inverse-base-h4(){} -// @mixin hook-inverse-base-h5(){} -// @mixin hook-inverse-base-h6(){} -// @mixin hook-inverse-base-blockquote(){} -// @mixin hook-inverse-base-blockquote-footer(){} -// @mixin hook-inverse-base-hr(){} diff --git a/docs/_sass/uikit/components/breadcrumb.scss b/docs/_sass/uikit/components/breadcrumb.scss deleted file mode 100644 index 3035391765..0000000000 --- a/docs/_sass/uikit/components/breadcrumb.scss +++ /dev/null @@ -1,123 +0,0 @@ -// Name: Breadcrumb -// Description: Component to create a breadcrumb navigation -// -// Component: `uk-breadcrumb` -// -// States: `uk-disabled` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$breadcrumb-item-font-size: $global-small-font-size !default; -$breadcrumb-item-color: $global-muted-color !default; -$breadcrumb-item-hover-color: $global-color !default; -$breadcrumb-item-hover-text-decoration: none !default; -$breadcrumb-item-active-color: $global-color !default; - -$breadcrumb-divider: "/" !default; -$breadcrumb-divider-margin-horizontal: 20px !default; -$breadcrumb-divider-font-size: $breadcrumb-item-font-size !default; -$breadcrumb-divider-color: $global-muted-color !default; - - -/* ======================================================================== - Component: Breadcrumb - ========================================================================== */ - -/* - * Reset list - */ - -.uk-breadcrumb { - padding: 0; - list-style: none; - @if(mixin-exists(hook-breadcrumb)) {@include hook-breadcrumb();} -} - -/* - * 1. Doesn't generate any box and replaced by child boxes - */ - -.uk-breadcrumb > * { display: contents; } - - -/* Items - ========================================================================== */ - -.uk-breadcrumb > * > * { - font-size: $breadcrumb-item-font-size; - color: $breadcrumb-item-color; - @if(mixin-exists(hook-breadcrumb-item)) {@include hook-breadcrumb-item();} -} - -/* Hover + Focus */ -.uk-breadcrumb > * > :hover, -.uk-breadcrumb > * > :focus { - color: $breadcrumb-item-hover-color; - text-decoration: $breadcrumb-item-hover-text-decoration; - @if(mixin-exists(hook-breadcrumb-item-hover)) {@include hook-breadcrumb-item-hover();} -} - -/* Disabled */ -.uk-breadcrumb > .uk-disabled > * { - @if(mixin-exists(hook-breadcrumb-item-disabled)) {@include hook-breadcrumb-item-disabled();} -} - -/* Active */ -.uk-breadcrumb > :last-child > span, -.uk-breadcrumb > :last-child > a:not([href]) { - color: $breadcrumb-item-active-color; - @if(mixin-exists(hook-breadcrumb-item-active)) {@include hook-breadcrumb-item-active();} -} - -/* - * Divider - * `nth-child` makes it also work without JS if it's only one row - * 1. Remove space between inline block elements. - * 2. Style - */ - -.uk-breadcrumb > :nth-child(n+2):not(.uk-first-column)::before { - content: $breadcrumb-divider; - display: inline-block; - /* 1 */ - margin: 0 $breadcrumb-divider-margin-horizontal 0 unquote('calc(#{$breadcrumb-divider-margin-horizontal} - 4px)'); - /* 2 */ - font-size: $breadcrumb-divider-font-size; - color: $breadcrumb-divider-color; - @if(mixin-exists(hook-breadcrumb-divider)) {@include hook-breadcrumb-divider();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-breadcrumb-misc)) {@include hook-breadcrumb-misc();} - -// @mixin hook-breadcrumb(){} -// @mixin hook-breadcrumb-item(){} -// @mixin hook-breadcrumb-item-hover(){} -// @mixin hook-breadcrumb-item-disabled(){} -// @mixin hook-breadcrumb-item-active(){} -// @mixin hook-breadcrumb-divider(){} -// @mixin hook-breadcrumb-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-breadcrumb-item-color: $inverse-global-muted-color !default; -$inverse-breadcrumb-item-hover-color: $inverse-global-color !default; -$inverse-breadcrumb-item-active-color: $inverse-global-color !default; -$inverse-breadcrumb-divider-color: $inverse-global-muted-color !default; - - - -// @mixin hook-inverse-breadcrumb-item(){} -// @mixin hook-inverse-breadcrumb-item-hover(){} -// @mixin hook-inverse-breadcrumb-item-disabled(){} -// @mixin hook-inverse-breadcrumb-item-active(){} -// @mixin hook-inverse-breadcrumb-divider(){} diff --git a/docs/_sass/uikit/components/button.scss b/docs/_sass/uikit/components/button.scss deleted file mode 100644 index 752791793f..0000000000 --- a/docs/_sass/uikit/components/button.scss +++ /dev/null @@ -1,452 +0,0 @@ -// Name: Button -// Description: Styles for buttons -// -// Component: `uk-button` -// -// Sub-objects: `uk-button-group` -// -// Modifiers: `uk-button-default` -// `uk-button-primary` -// `uk-button-secondary` -// `uk-button-danger` -// `uk-button-text` -// `uk-button-link` -// `uk-button-small` -// `uk-button-large` -// -// States: `uk-active` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$button-line-height: $global-control-height !default; -$button-small-line-height: $global-control-small-height !default; -$button-large-line-height: $global-control-large-height !default; - -$button-font-size: $global-font-size !default; -$button-small-font-size: $global-small-font-size !default; -$button-large-font-size: $global-medium-font-size !default; - -$button-padding-horizontal: $global-gutter !default; -$button-small-padding-horizontal: $global-small-gutter !default; -$button-large-padding-horizontal: $global-medium-gutter !default; - -$button-default-background: $global-muted-background !default; -$button-default-color: $global-emphasis-color !default; -$button-default-hover-background: darken($button-default-background, 5%) !default; -$button-default-hover-color: $global-emphasis-color !default; -$button-default-active-background: darken($button-default-background, 10%) !default; -$button-default-active-color: $global-emphasis-color !default; - -$button-primary-background: $global-primary-background !default; -$button-primary-color: $global-inverse-color !default; -$button-primary-hover-background: darken($button-primary-background, 5%) !default; -$button-primary-hover-color: $global-inverse-color !default; -$button-primary-active-background: darken($button-primary-background, 10%) !default; -$button-primary-active-color: $global-inverse-color !default; - -$button-secondary-background: $global-secondary-background !default; -$button-secondary-color: $global-inverse-color !default; -$button-secondary-hover-background: darken($button-secondary-background, 5%) !default; -$button-secondary-hover-color: $global-inverse-color !default; -$button-secondary-active-background: darken($button-secondary-background, 10%) !default; -$button-secondary-active-color: $global-inverse-color !default; - -$button-danger-background: $global-danger-background !default; -$button-danger-color: $global-inverse-color !default; -$button-danger-hover-background: darken($button-danger-background, 5%) !default; -$button-danger-hover-color: $global-inverse-color !default; -$button-danger-active-background: darken($button-danger-background, 10%) !default; -$button-danger-active-color: $global-inverse-color !default; - -$button-disabled-background: $global-muted-background !default; -$button-disabled-color: $global-muted-color !default; - -$button-text-line-height: $global-line-height !default; -$button-text-color: $global-emphasis-color !default; -$button-text-hover-color: $global-muted-color !default; -$button-text-disabled-color: $global-muted-color !default; - -$button-link-line-height: $global-line-height !default; -$button-link-color: $global-emphasis-color !default; -$button-link-hover-color: $global-muted-color !default; -$button-link-hover-text-decoration: none !default; -$button-link-disabled-color: $global-muted-color !default; - - -/* ======================================================================== - Component: Button - ========================================================================== */ - -/* - * 1. Remove margins in Chrome, Safari and Opera. - * 2. Remove borders for `button`. - * 3. Address `overflow` set to `hidden` in IE. - * 4. Correct `font` properties and `color` not being inherited for `button`. - * 5. Remove the inheritance of text transform in Edge, Firefox, and IE. - * 6. Remove default style for `input type="submit"`in iOS. - * 7. Style - * 8. `line-height` is used to create a height because it also centers the text vertically for `a` elements. - * Better would be to use height and flexbox to center the text vertically but flexbox doesn't work in Firefox on `button` elements. - * 9. Align text if button has a width - * 10. Required for `a`. - */ - -.uk-button { - /* 1 */ - margin: 0; - /* 2 */ - border: none; - /* 3 */ - overflow: visible; - /* 4 */ - font: inherit; - color: inherit; - /* 5 */ - text-transform: none; - /* 6 */ - -webkit-appearance: none; - border-radius: 0; - /* 7 */ - display: inline-block; - box-sizing: border-box; - padding: 0 $button-padding-horizontal; - vertical-align: middle; - font-size: $button-font-size; - /* 8 */ - line-height: $button-line-height; - /* 9 */ - text-align: center; - /* 10 */ - text-decoration: none; - @if(mixin-exists(hook-button)) {@include hook-button();} -} - -.uk-button:not(:disabled) { cursor: pointer; } - -/* - * Remove the inner border and padding in Firefox. - */ - -.uk-button::-moz-focus-inner { - border: 0; - padding: 0; -} - -/* Hover */ -.uk-button:hover { - /* 9 */ - text-decoration: none; - @if(mixin-exists(hook-button-hover)) {@include hook-button-hover();} -} - -/* Focus */ -.uk-button:focus { - outline: none; - @if(mixin-exists(hook-button-focus)) {@include hook-button-focus();} -} - -/* OnClick + Active */ -.uk-button:active, -.uk-button.uk-active { - @if(mixin-exists(hook-button-active)) {@include hook-button-active();} -} - - -/* Style modifiers - ========================================================================== */ - -/* - * Default - */ - -.uk-button-default { - background-color: $button-default-background; - color: $button-default-color; - @if(mixin-exists(hook-button-default)) {@include hook-button-default();} -} - -/* Hover + Focus */ -.uk-button-default:hover, -.uk-button-default:focus { - background-color: $button-default-hover-background; - color: $button-default-hover-color; - @if(mixin-exists(hook-button-default-hover)) {@include hook-button-default-hover();} -} - -/* OnClick + Active */ -.uk-button-default:active, -.uk-button-default.uk-active { - background-color: $button-default-active-background; - color: $button-default-active-color; - @if(mixin-exists(hook-button-default-active)) {@include hook-button-default-active();} -} - -/* - * Primary - */ - -.uk-button-primary { - background-color: $button-primary-background; - color: $button-primary-color; - @if(mixin-exists(hook-button-primary)) {@include hook-button-primary();} -} - -/* Hover + Focus */ -.uk-button-primary:hover, -.uk-button-primary:focus { - background-color: $button-primary-hover-background; - color: $button-primary-hover-color; - @if(mixin-exists(hook-button-primary-hover)) {@include hook-button-primary-hover();} -} - -/* OnClick + Active */ -.uk-button-primary:active, -.uk-button-primary.uk-active { - background-color: $button-primary-active-background; - color: $button-primary-active-color; - @if(mixin-exists(hook-button-primary-active)) {@include hook-button-primary-active();} -} - -/* - * Secondary - */ - -.uk-button-secondary { - background-color: $button-secondary-background; - color: $button-secondary-color; - @if(mixin-exists(hook-button-secondary)) {@include hook-button-secondary();} -} - -/* Hover + Focus */ -.uk-button-secondary:hover, -.uk-button-secondary:focus { - background-color: $button-secondary-hover-background; - color: $button-secondary-hover-color; - @if(mixin-exists(hook-button-secondary-hover)) {@include hook-button-secondary-hover();} -} - -/* OnClick + Active */ -.uk-button-secondary:active, -.uk-button-secondary.uk-active { - background-color: $button-secondary-active-background; - color: $button-secondary-active-color; - @if(mixin-exists(hook-button-secondary-active)) {@include hook-button-secondary-active();} -} - -/* - * Danger - */ - -.uk-button-danger { - background-color: $button-danger-background; - color: $button-danger-color; - @if(mixin-exists(hook-button-danger)) {@include hook-button-danger();} -} - -/* Hover + Focus */ -.uk-button-danger:hover, -.uk-button-danger:focus { - background-color: $button-danger-hover-background; - color: $button-danger-hover-color; - @if(mixin-exists(hook-button-danger-hover)) {@include hook-button-danger-hover();} -} - -/* OnClick + Active */ -.uk-button-danger:active, -.uk-button-danger.uk-active { - background-color: $button-danger-active-background; - color: $button-danger-active-color; - @if(mixin-exists(hook-button-danger-active)) {@include hook-button-danger-active();} -} - -/* - * Disabled - * The same for all style modifiers - */ - -.uk-button-default:disabled, -.uk-button-primary:disabled, -.uk-button-secondary:disabled, -.uk-button-danger:disabled { - background-color: $button-disabled-background; - color: $button-disabled-color; - @if(mixin-exists(hook-button-disabled)) {@include hook-button-disabled();} -} - - -/* Size modifiers - ========================================================================== */ - -.uk-button-small { - padding: 0 $button-small-padding-horizontal; - line-height: $button-small-line-height; - font-size: $button-small-font-size; - @if(mixin-exists(hook-button-small)) {@include hook-button-small();} -} - -.uk-button-large { - padding: 0 $button-large-padding-horizontal; - line-height: $button-large-line-height; - font-size: $button-large-font-size; - @if(mixin-exists(hook-button-large)) {@include hook-button-large();} -} - - -/* Text modifiers - ========================================================================== */ - -/* - * Text - * 1. Reset - * 2. Style - */ - -.uk-button-text { - /* 1 */ - padding: 0; - line-height: $button-text-line-height; - background: none; - /* 2 */ - color: $button-text-color; - @if(mixin-exists(hook-button-text)) {@include hook-button-text();} -} - -/* Hover + Focus */ -.uk-button-text:hover, -.uk-button-text:focus { - color: $button-text-hover-color; - @if(mixin-exists(hook-button-text-hover)) {@include hook-button-text-hover();} -} - -/* Disabled */ -.uk-button-text:disabled { - color: $button-text-disabled-color; - @if(mixin-exists(hook-button-text-disabled)) {@include hook-button-text-disabled();} -} - -/* - * Link - * 1. Reset - * 2. Style - */ - -.uk-button-link { - /* 1 */ - padding: 0; - line-height: $button-link-line-height; - background: none; - /* 2 */ - color: $button-link-color; - @if(mixin-exists(hook-button-link)) {@include hook-button-link();} -} - -/* Hover + Focus */ -.uk-button-link:hover, -.uk-button-link:focus { - color: $button-link-hover-color; - text-decoration: $button-link-hover-text-decoration; -} - -/* Disabled */ -.uk-button-link:disabled { - color: $button-link-disabled-color; - text-decoration: none; -} - - -/* Group - ========================================================================== */ - -/* - * 1. Using `flex` instead of `inline-block` to prevent whitespace betweent child elements - * 2. Behave like button - * 3. Create position context - */ - -.uk-button-group { - /* 1 */ - display: inline-flex; - /* 2 */ - vertical-align: middle; - /* 3 */ - position: relative; -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-button-misc)) {@include hook-button-misc();} - -// @mixin hook-button(){} -// @mixin hook-button-hover(){} -// @mixin hook-button-focus(){} -// @mixin hook-button-active(){} -// @mixin hook-button-default(){} -// @mixin hook-button-default-hover(){} -// @mixin hook-button-default-active(){} -// @mixin hook-button-primary(){} -// @mixin hook-button-primary-hover(){} -// @mixin hook-button-primary-active(){} -// @mixin hook-button-secondary(){} -// @mixin hook-button-secondary-hover(){} -// @mixin hook-button-secondary-active(){} -// @mixin hook-button-danger(){} -// @mixin hook-button-danger-hover(){} -// @mixin hook-button-danger-active(){} -// @mixin hook-button-disabled(){} -// @mixin hook-button-small(){} -// @mixin hook-button-large(){} -// @mixin hook-button-text(){} -// @mixin hook-button-text-hover(){} -// @mixin hook-button-text-disabled(){} -// @mixin hook-button-link(){} -// @mixin hook-button-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-button-default-background: $inverse-global-primary-background !default; -$inverse-button-default-color: $inverse-global-inverse-color !default; -$inverse-button-default-hover-background: darken($inverse-button-default-background, 5%) !default; -$inverse-button-default-hover-color: $inverse-global-inverse-color !default; -$inverse-button-default-active-background: darken($inverse-button-default-background, 10%) !default; -$inverse-button-default-active-color: $inverse-global-inverse-color !default; -$inverse-button-primary-background: $inverse-global-primary-background !default; -$inverse-button-primary-color: $inverse-global-inverse-color !default; -$inverse-button-primary-hover-background: darken($inverse-button-primary-background, 5%) !default; -$inverse-button-primary-hover-color: $inverse-global-inverse-color !default; -$inverse-button-primary-active-background: darken($inverse-button-primary-background, 10%) !default; -$inverse-button-primary-active-color: $inverse-global-inverse-color !default; -$inverse-button-secondary-background: $inverse-global-primary-background !default; -$inverse-button-secondary-color: $inverse-global-inverse-color !default; -$inverse-button-secondary-hover-background: darken($inverse-button-secondary-background, 5%) !default; -$inverse-button-secondary-hover-color: $inverse-global-inverse-color !default; -$inverse-button-secondary-active-background: darken($inverse-button-secondary-background, 10%) !default; -$inverse-button-secondary-active-color: $inverse-global-inverse-color !default; -$inverse-button-text-color: $inverse-global-emphasis-color !default; -$inverse-button-text-hover-color: $inverse-global-muted-color !default; -$inverse-button-text-disabled-color: $inverse-global-muted-color !default; -$inverse-button-link-color: $inverse-global-emphasis-color !default; -$inverse-button-link-hover-color: $inverse-global-muted-color !default; - - - -// @mixin hook-inverse-button-default(){} -// @mixin hook-inverse-button-default-hover(){} -// @mixin hook-inverse-button-default-active(){} -// @mixin hook-inverse-button-primary(){} -// @mixin hook-inverse-button-primary-hover(){} -// @mixin hook-inverse-button-primary-active(){} -// @mixin hook-inverse-button-secondary(){} -// @mixin hook-inverse-button-secondary-hover(){} -// @mixin hook-inverse-button-secondary-active(){} -// @mixin hook-inverse-button-text(){} -// @mixin hook-inverse-button-text-hover(){} -// @mixin hook-inverse-button-text-disabled(){} -// @mixin hook-inverse-button-link(){} diff --git a/docs/_sass/uikit/components/card.scss b/docs/_sass/uikit/components/card.scss deleted file mode 100644 index e529cc0eec..0000000000 --- a/docs/_sass/uikit/components/card.scss +++ /dev/null @@ -1,384 +0,0 @@ -// Name: Card -// Description: Component to create boxed content containers -// -// Component: `uk-card` -// -// Sub-objects: `uk-card-body` -// `uk-card-header` -// `uk-card-footer` -// `uk-card-media-*` -// `uk-card-title` -// `uk-card-badge` -// -// Modifiers: `uk-card-hover` -// `uk-card-default` -// `uk-card-primary` -// `uk-card-secondary` -// `uk-card-small` -// `uk-card-large` -// -// Uses: `uk-grid-stack` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$card-body-padding-horizontal: $global-gutter !default; -$card-body-padding-vertical: $global-gutter !default; - -$card-body-padding-horizontal-l: $global-medium-gutter !default; -$card-body-padding-vertical-l: $global-medium-gutter !default; - -$card-header-padding-horizontal: $global-gutter !default; -$card-header-padding-vertical: round($global-gutter / 2) !default; - -$card-header-padding-horizontal-l: $global-medium-gutter !default; -$card-header-padding-vertical-l: round($global-medium-gutter / 2) !default; - -$card-footer-padding-horizontal: $global-gutter !default; -$card-footer-padding-vertical: ($global-gutter / 2) !default; - -$card-footer-padding-horizontal-l: $global-medium-gutter !default; -$card-footer-padding-vertical-l: round($global-medium-gutter / 2) !default; - -$card-title-font-size: $global-large-font-size !default; -$card-title-line-height: 1.4 !default; - -$card-badge-top: 15px !default; -$card-badge-right: 15px !default; -$card-badge-height: 22px !default; -$card-badge-padding-horizontal: 10px !default; -$card-badge-background: $global-primary-background !default; -$card-badge-color: $global-inverse-color !default; -$card-badge-font-size: $global-small-font-size !default; - -$card-hover-background: $global-muted-background !default; - -$card-default-background: $global-muted-background !default; -$card-default-color: $global-color !default; -$card-default-title-color: $global-emphasis-color !default; -$card-default-hover-background: darken($card-default-background, 5%) !default; - -$card-primary-background: $global-primary-background !default; -$card-primary-color: $global-inverse-color !default; -$card-primary-title-color: $card-primary-color !default; -$card-primary-hover-background: darken($card-primary-background, 5%) !default; -$card-primary-color-mode: light !default; - -$card-secondary-background: $global-secondary-background !default; -$card-secondary-color: $global-inverse-color !default; -$card-secondary-title-color: $card-secondary-color !default; -$card-secondary-hover-background: darken($card-secondary-background, 5%) !default; -$card-secondary-color-mode: light !default; - -$card-small-body-padding-horizontal: $global-margin !default; -$card-small-body-padding-vertical: $global-margin !default; -$card-small-header-padding-horizontal: $global-margin !default; -$card-small-header-padding-vertical: round($global-margin / 1.5) !default; -$card-small-footer-padding-horizontal: $global-margin !default; -$card-small-footer-padding-vertical: round($global-margin / 1.5) !default; - -$card-large-body-padding-horizontal-l: $global-large-gutter !default; -$card-large-body-padding-vertical-l: $global-large-gutter !default; -$card-large-header-padding-horizontal-l: $global-large-gutter !default; -$card-large-header-padding-vertical-l: round($global-large-gutter / 2) !default; -$card-large-footer-padding-horizontal-l: $global-large-gutter !default; -$card-large-footer-padding-vertical-l: round($global-large-gutter / 2) !default; - - -/* ======================================================================== - Component: Card - ========================================================================== */ - -.uk-card { - position: relative; - box-sizing: border-box; - @if(mixin-exists(hook-card)) {@include hook-card();} -} - - -/* Sections - ========================================================================== */ - -.uk-card-body { - display: flow-root; - padding: $card-body-padding-vertical $card-body-padding-horizontal; - @if(mixin-exists(hook-card-body)) {@include hook-card-body();} -} - -.uk-card-header { - display: flow-root; - padding: $card-header-padding-vertical $card-header-padding-horizontal; - @if(mixin-exists(hook-card-header)) {@include hook-card-header();} -} - -.uk-card-footer { - display: flow-root; - padding: $card-footer-padding-vertical $card-footer-padding-horizontal; - @if(mixin-exists(hook-card-footer)) {@include hook-card-footer();} -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-card-body { padding: $card-body-padding-vertical-l $card-body-padding-horizontal-l; } - - .uk-card-header { padding: $card-header-padding-vertical-l $card-header-padding-horizontal-l; } - - .uk-card-footer { padding: $card-footer-padding-vertical-l $card-footer-padding-horizontal-l; } - -} - -/* - * Remove margin from the last-child - */ - -.uk-card-body > :last-child, -.uk-card-header > :last-child, -.uk-card-footer > :last-child { margin-bottom: 0; } - - -/* Media - ========================================================================== */ - -/* - * Reserved alignment modifier to style the media element, e.g. with `border-radius` - * Implemented by the theme - */ - -[class*='uk-card-media'] { - @if(mixin-exists(hook-card-media)) {@include hook-card-media();} -} - -.uk-card-media-top, -.uk-grid-stack > .uk-card-media-left, -.uk-grid-stack > .uk-card-media-right { - @if(mixin-exists(hook-card-media-top)) {@include hook-card-media-top();} -} - -.uk-card-media-bottom { - @if(mixin-exists(hook-card-media-bottom)) {@include hook-card-media-bottom();} -} - -:not(.uk-grid-stack) > .uk-card-media-left { - @if(mixin-exists(hook-card-media-left)) {@include hook-card-media-left();} -} - -:not(.uk-grid-stack) > .uk-card-media-right { - @if(mixin-exists(hook-card-media-right)) {@include hook-card-media-right();} -} - - -/* Title - ========================================================================== */ - -.uk-card-title { - font-size: $card-title-font-size; - line-height: $card-title-line-height; - @if(mixin-exists(hook-card-title)) {@include hook-card-title();} -} - - -/* Badge - ========================================================================== */ - -/* - * 1. Position - * 2. Size - * 3. Style - * 4. Center child vertically - */ - -.uk-card-badge { - /* 1 */ - position: absolute; - top: $card-badge-top; - right: $card-badge-right; - z-index: 1; - /* 2 */ - height: $card-badge-height; - padding: 0 $card-badge-padding-horizontal; - /* 3 */ - background: $card-badge-background; - color: $card-badge-color; - font-size: $card-badge-font-size; - /* 4 */ - display: flex; - justify-content: center; - align-items: center; - line-height: 0; - @if(mixin-exists(hook-card-badge)) {@include hook-card-badge();} -} - -/* - * Remove margin from adjacent element - */ - -.uk-card-badge:first-child + * { margin-top: 0; } - - -/* Hover modifier - ========================================================================== */ - -.uk-card-hover:not(.uk-card-default):not(.uk-card-primary):not(.uk-card-secondary):hover { - background: $card-hover-background; - @if(mixin-exists(hook-card-hover)) {@include hook-card-hover();} -} - - -/* Style modifiers - ========================================================================== */ - -/* - * Default - * Note: Header and Footer are only implemented for the default style - */ - -.uk-card-default { - background: $card-default-background; - color: $card-default-color; - @if(mixin-exists(hook-card-default)) {@include hook-card-default();} -} - -.uk-card-default .uk-card-title { - color: $card-default-title-color; - @if(mixin-exists(hook-card-default-title)) {@include hook-card-default-title();} -} - -.uk-card-default.uk-card-hover:hover { - background-color: $card-default-hover-background; - @if(mixin-exists(hook-card-default-hover)) {@include hook-card-default-hover();} -} - -.uk-card-default .uk-card-header { - @if(mixin-exists(hook-card-default-header)) {@include hook-card-default-header();} -} - -.uk-card-default .uk-card-footer { - @if(mixin-exists(hook-card-default-footer)) {@include hook-card-default-footer();} -} - -/* - * Primary - */ - -.uk-card-primary { - background: $card-primary-background; - color: $card-primary-color; - @if(mixin-exists(hook-card-primary)) {@include hook-card-primary();} -} - -.uk-card-primary .uk-card-title { - color: $card-primary-title-color; - @if(mixin-exists(hook-card-primary-title)) {@include hook-card-primary-title();} -} - -.uk-card-primary.uk-card-hover:hover { - background-color: $card-primary-hover-background; - @if(mixin-exists(hook-card-primary-hover)) {@include hook-card-primary-hover();} -} - -// Color Mode -@if ( $card-primary-color-mode == light ) { .uk-card-primary.uk-card-body { @extend .uk-light !optional;} } -@if ( $card-primary-color-mode == light ) { .uk-card-primary > :not([class*='uk-card-media']) { @extend .uk-light !optional;} } -@if ( $card-primary-color-mode == dark ) { .uk-card-primary.uk-card-body { @extend .uk-dark !optional;} } -@if ( $card-primary-color-mode == dark ) { .uk-card-primary > :not([class*='uk-card-media']) { @extend .uk-dark !optional;} } - -/* - * Secondary - */ - -.uk-card-secondary { - background: $card-secondary-background; - color: $card-secondary-color; - @if(mixin-exists(hook-card-secondary)) {@include hook-card-secondary();} -} - -.uk-card-secondary .uk-card-title { - color: $card-secondary-title-color; - @if(mixin-exists(hook-card-secondary-title)) {@include hook-card-secondary-title();} -} - -.uk-card-secondary.uk-card-hover:hover { - background-color: $card-secondary-hover-background; - @if(mixin-exists(hook-card-secondary-hover)) {@include hook-card-secondary-hover();} -} - -// Color Mode -@if ( $card-secondary-color-mode == light ) { .uk-card-secondary.uk-card-body { @extend .uk-light !optional;} } -@if ( $card-secondary-color-mode == light ) { .uk-card-secondary > :not([class*='uk-card-media']) { @extend .uk-light !optional;} } -@if ( $card-secondary-color-mode == dark ) { .uk-card-secondary.uk-card-body { @extend .uk-dark !optional;} } -@if ( $card-secondary-color-mode == dark ) { .uk-card-secondary > :not([class*='uk-card-media']) { @extend .uk-dark !optional;} } - - -/* Size modifier - ========================================================================== */ - -/* - * Small - */ - -.uk-card-small.uk-card-body, -.uk-card-small .uk-card-body { padding: $card-small-body-padding-vertical $card-small-body-padding-horizontal; } - -.uk-card-small .uk-card-header { padding: $card-small-header-padding-vertical $card-small-header-padding-horizontal; } -.uk-card-small .uk-card-footer { padding: $card-small-footer-padding-vertical $card-small-footer-padding-horizontal; } - -/* - * Large - */ - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-card-large.uk-card-body, - .uk-card-large .uk-card-body { padding: $card-large-body-padding-vertical-l $card-large-body-padding-horizontal-l; } - - .uk-card-large .uk-card-header { padding: $card-large-header-padding-vertical-l $card-large-header-padding-horizontal-l; } - .uk-card-large .uk-card-footer { padding: $card-large-footer-padding-vertical-l $card-large-footer-padding-horizontal-l; } - -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-card-misc)) {@include hook-card-misc();} - -// @mixin hook-card(){} -// @mixin hook-card-body(){} -// @mixin hook-card-header(){} -// @mixin hook-card-footer(){} -// @mixin hook-card-media(){} -// @mixin hook-card-media-top(){} -// @mixin hook-card-media-bottom(){} -// @mixin hook-card-media-left(){} -// @mixin hook-card-media-right(){} -// @mixin hook-card-title(){} -// @mixin hook-card-badge(){} -// @mixin hook-card-hover(){} -// @mixin hook-card-default(){} -// @mixin hook-card-default-title(){} -// @mixin hook-card-default-hover(){} -// @mixin hook-card-default-header(){} -// @mixin hook-card-default-footer(){} -// @mixin hook-card-primary(){} -// @mixin hook-card-primary-title(){} -// @mixin hook-card-primary-hover(){} -// @mixin hook-card-secondary(){} -// @mixin hook-card-secondary-title(){} -// @mixin hook-card-secondary-hover(){} -// @mixin hook-card-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-card-badge-background: $inverse-global-primary-background !default; -$inverse-card-badge-color: $inverse-global-inverse-color !default; - - - -// @mixin hook-inverse-card-badge(){} diff --git a/docs/_sass/uikit/components/close.scss b/docs/_sass/uikit/components/close.scss deleted file mode 100644 index 32e2775642..0000000000 --- a/docs/_sass/uikit/components/close.scss +++ /dev/null @@ -1,57 +0,0 @@ -// Name: Close -// Description: Component to create a close button -// -// Component: `uk-close` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$close-color: $global-muted-color !default; -$close-hover-color: $global-color !default; - - -/* ======================================================================== - Component: Close - ========================================================================== */ - -/* - * Adopts `uk-icon` - */ - -.uk-close { - color: $close-color; - @if(mixin-exists(hook-close)) {@include hook-close();} -} - -/* Hover + Focus */ -.uk-close:hover, -.uk-close:focus { - color: $close-hover-color; - outline: none; - @if(mixin-exists(hook-close-hover)) {@include hook-close-hover();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-close-misc)) {@include hook-close-misc();} - -// @mixin hook-close(){} -// @mixin hook-close-hover(){} -// @mixin hook-close-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-close-color: $inverse-global-muted-color !default; -$inverse-close-hover-color: $inverse-global-color !default; - - - -// @mixin hook-inverse-close(){} -// @mixin hook-inverse-close-hover(){} diff --git a/docs/_sass/uikit/components/column.scss b/docs/_sass/uikit/components/column.scss deleted file mode 100644 index 54bae26e37..0000000000 --- a/docs/_sass/uikit/components/column.scss +++ /dev/null @@ -1,138 +0,0 @@ -// Name: Column -// Description: Utilities for text columns -// -// Component: `uk-column-*` -// -// Sub-objects: `uk-column-span` -// -// Modifiers: `uk-column-divider` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$column-gutter: $global-gutter !default; -$column-gutter-l: $global-medium-gutter !default; - -$column-divider-rule-color: $global-border !default; -$column-divider-rule-width: 1px !default; - - -/* ======================================================================== - Component: Column - ========================================================================== */ - -[class*='uk-column-'] { column-gap: $column-gutter; } - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - [class*='uk-column-'] { column-gap: $column-gutter-l; } - -} - -/* - * Fix image 1px line wrapping into the next column in Chrome - */ - -[class*='uk-column-'] img { transform: translate3d(0,0,0); } - - -/* Divider - ========================================================================== */ - -/* - * 1. Double the column gap - */ - -.uk-column-divider { - column-rule: $column-divider-rule-width solid $column-divider-rule-color; - /* 1 */ - column-gap: ($column-gutter * 2); -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-column-divider { - column-gap: ($column-gutter-l * 2); - } - -} - - -/* Width modifiers - ========================================================================== */ - -.uk-column-1-2 { column-count: 2;} -.uk-column-1-3 { column-count: 3; } -.uk-column-1-4 { column-count: 4; } -.uk-column-1-5 { column-count: 5; } -.uk-column-1-6 { column-count: 6; } - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-column-1-2\@s { column-count: 2; } - .uk-column-1-3\@s { column-count: 3; } - .uk-column-1-4\@s { column-count: 4; } - .uk-column-1-5\@s { column-count: 5; } - .uk-column-1-6\@s { column-count: 6; } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-column-1-2\@m { column-count: 2; } - .uk-column-1-3\@m { column-count: 3; } - .uk-column-1-4\@m { column-count: 4; } - .uk-column-1-5\@m { column-count: 5; } - .uk-column-1-6\@m { column-count: 6; } - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-column-1-2\@l { column-count: 2; } - .uk-column-1-3\@l { column-count: 3; } - .uk-column-1-4\@l { column-count: 4; } - .uk-column-1-5\@l { column-count: 5; } - .uk-column-1-6\@l { column-count: 6; } - -} - -/* Large screen and bigger */ -@media (min-width: $breakpoint-xlarge) { - - .uk-column-1-2\@xl { column-count: 2; } - .uk-column-1-3\@xl { column-count: 3; } - .uk-column-1-4\@xl { column-count: 4; } - .uk-column-1-5\@xl { column-count: 5; } - .uk-column-1-6\@xl { column-count: 6; } - -} - -/* Make element span across all columns - * Does not work in Firefox yet - ========================================================================== */ - -.uk-column-span { column-span: all; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-column-misc)) {@include hook-column-misc();} - -// @mixin hook-column-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-column-divider-rule-color: $inverse-global-border !default; - diff --git a/docs/_sass/uikit/components/comment.scss b/docs/_sass/uikit/components/comment.scss deleted file mode 100644 index 212b096016..0000000000 --- a/docs/_sass/uikit/components/comment.scss +++ /dev/null @@ -1,160 +0,0 @@ -// Name: Comment -// Description: Component to create nested comments -// -// Component: `uk-comment` -// -// Sub-objects: `uk-comment-body` -// `uk-comment-header` -// `uk-comment-title` -// `uk-comment-meta` -// `uk-comment-avatar` -// `uk-comment-list` -// -// Modifier: `uk-comment-primary` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$comment-header-margin-bottom: $global-margin !default; - -$comment-title-font-size: $global-medium-font-size !default; -$comment-title-line-height: 1.4 !default; - -$comment-meta-font-size: $global-small-font-size !default; -$comment-meta-line-height: 1.4 !default; -$comment-meta-color: $global-muted-color !default; - -$comment-list-margin-top: $global-large-margin !default; -$comment-list-padding-left: 30px !default; -$comment-list-padding-left-m: 100px !default; - - -/* ======================================================================== - Component: Comment - ========================================================================== */ - -.uk-comment { - @if(mixin-exists(hook-comment)) {@include hook-comment();} -} - - -/* Sections - ========================================================================== */ - -.uk-comment-body { - display: flow-root; - overflow-wrap: break-word; - word-wrap: break-word; - @if(mixin-exists(hook-comment-body)) {@include hook-comment-body();} -} - -.uk-comment-header { - display: flow-root; - margin-bottom: $comment-header-margin-bottom; - @if(mixin-exists(hook-comment-header)) {@include hook-comment-header();} -} - -/* - * Remove margin from the last-child - */ - -.uk-comment-body > :last-child, -.uk-comment-header > :last-child { margin-bottom: 0; } - - -/* Title - ========================================================================== */ - -.uk-comment-title { - font-size: $comment-title-font-size; - line-height: $comment-title-line-height; - @if(mixin-exists(hook-comment-title)) {@include hook-comment-title();} -} - - -/* Meta - ========================================================================== */ - -.uk-comment-meta { - font-size: $comment-meta-font-size; - line-height: $comment-meta-line-height; - color: $comment-meta-color; - @if(mixin-exists(hook-comment-meta)) {@include hook-comment-meta();} -} - - -/* Avatar - ========================================================================== */ - -.uk-comment-avatar { - @if(mixin-exists(hook-comment-avatar)) {@include hook-comment-avatar();} -} - - -/* List - ========================================================================== */ - -.uk-comment-list { - padding: 0; - list-style: none; -} - -/* Adjacent siblings */ -.uk-comment-list > :nth-child(n+2) { - margin-top: $comment-list-margin-top; - @if(mixin-exists(hook-comment-list-adjacent)) {@include hook-comment-list-adjacent();} -} - -/* - * Sublists - * Note: General sibling selector allows reply block between comment and sublist - */ - -.uk-comment-list .uk-comment ~ ul { - margin: $comment-list-margin-top 0 0 0; - padding-left: $comment-list-padding-left; - list-style: none; - @if(mixin-exists(hook-comment-list-sub)) {@include hook-comment-list-sub();} -} - -/* Tablet and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-comment-list .uk-comment ~ ul { padding-left: $comment-list-padding-left-m; } - -} - -/* Adjacent siblings */ -.uk-comment-list .uk-comment ~ ul > :nth-child(n+2) { - margin-top: $comment-list-margin-top; - @if(mixin-exists(hook-comment-list-sub-adjacent)) {@include hook-comment-list-sub-adjacent();} -} - - -/* Style modifier - ========================================================================== */ - -.uk-comment-primary { - @if(mixin-exists(hook-comment-primary)) {@include hook-comment-primary();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-comment-misc)) {@include hook-comment-misc();} - -// @mixin hook-comment(){} -// @mixin hook-comment-body(){} -// @mixin hook-comment-header(){} -// @mixin hook-comment-title(){} -// @mixin hook-comment-meta(){} -// @mixin hook-comment-avatar(){} -// @mixin hook-comment-list-adjacent(){} -// @mixin hook-comment-list-sub(){} -// @mixin hook-comment-list-sub-adjacent(){} -// @mixin hook-comment-primary(){} -// @mixin hook-comment-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/container.scss b/docs/_sass/uikit/components/container.scss deleted file mode 100644 index dcf798a220..0000000000 --- a/docs/_sass/uikit/components/container.scss +++ /dev/null @@ -1,185 +0,0 @@ -// Name: Container -// Description: Component to align and center your site and grid content -// -// Component: `uk-container` -// -// Modifier: `uk-container-small` -// `uk-container-large` -// `uk-container-expand` -// `uk-container-expand-left` -// `uk-container-expand-right` -// `uk-container-item-padding-remove-left` -// `uk-container-item-padding-remove-right` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$container-max-width: 1200px !default; -$container-xsmall-max-width: 750px !default; -$container-small-max-width: 900px !default; -$container-large-max-width: 1400px !default; -$container-xlarge-max-width: 1600px !default; - -$container-padding-horizontal: 15px !default; -$container-padding-horizontal-s: $global-gutter !default; -$container-padding-horizontal-m: $global-medium-gutter !default; - - -/* ======================================================================== - Component: Container - ========================================================================== */ - -/* - * 1. Box sizing has to be `content-box` so the max-width is always the same and - * unaffected by the padding on different breakpoints. It's important for the size modifiers. - */ - -.uk-container { - display: flow-root; - /* 1 */ - box-sizing: content-box; - max-width: $container-max-width; - margin-left: auto; - margin-right: auto; - padding-left: $container-padding-horizontal; - padding-right: $container-padding-horizontal; -} - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-container { - padding-left: $container-padding-horizontal-s; - padding-right: $container-padding-horizontal-s; - } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-container { - padding-left: $container-padding-horizontal-m; - padding-right: $container-padding-horizontal-m; - } - -} - -/* - * Remove margin from the last-child - */ - -.uk-container > :last-child { margin-bottom: 0; } - -/* - * Remove padding from nested containers - */ - -.uk-container .uk-container { - padding-left: 0; - padding-right: 0; -} - - -/* Size modifier - ========================================================================== */ - -.uk-container-xsmall { max-width: $container-xsmall-max-width; } - -.uk-container-small { max-width: $container-small-max-width; } - -.uk-container-large { max-width: $container-large-max-width; } - -.uk-container-xlarge { max-width: $container-xlarge-max-width; } - -.uk-container-expand { max-width: none; } - - -/* Expand modifier - ========================================================================== */ - -/* - * Expand one side only - */ - -.uk-container-expand-left { margin-left: 0; } -.uk-container-expand-right { margin-right: 0; } - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-container-expand-left.uk-container-xsmall, - .uk-container-expand-right.uk-container-xsmall { max-width: unquote('calc(50% + (#{$container-xsmall-max-width} / 2) - #{$container-padding-horizontal-s})'); } - - .uk-container-expand-left.uk-container-small, - .uk-container-expand-right.uk-container-small { max-width: unquote('calc(50% + (#{$container-small-max-width} / 2) - #{$container-padding-horizontal-s})'); } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-container-expand-left, - .uk-container-expand-right { max-width: unquote('calc(50% + (#{$container-max-width} / 2) - #{$container-padding-horizontal-m})'); } - - .uk-container-expand-left.uk-container-xsmall, - .uk-container-expand-right.uk-container-xsmall { max-width: unquote('calc(50% + (#{$container-xsmall-max-width} / 2) - #{$container-padding-horizontal-m})'); } - - .uk-container-expand-left.uk-container-small, - .uk-container-expand-right.uk-container-small { max-width: unquote('calc(50% + (#{$container-small-max-width} / 2) - #{$container-padding-horizontal-m})'); } - - .uk-container-expand-left.uk-container-large, - .uk-container-expand-right.uk-container-large { max-width: unquote('calc(50% + (#{$container-large-max-width} / 2) - #{$container-padding-horizontal-m})'); } - - .uk-container-expand-left.uk-container-xlarge, - .uk-container-expand-right.uk-container-xlarge { max-width: unquote('calc(50% + (#{$container-xlarge-max-width} / 2) - #{$container-padding-horizontal-m})'); } - -} - - -/* Item - ========================================================================== */ - -/* - * Utility classes to reset container padding on the left or right side - * Note: It has to be negative margin on the item, because it's specific to the item. - */ - -.uk-container-item-padding-remove-left, -.uk-container-item-padding-remove-right { width: unquote('calc(100% + #{$container-padding-horizontal})') } - -.uk-container-item-padding-remove-left { margin-left: (-$container-padding-horizontal); } -.uk-container-item-padding-remove-right { margin-right: (-$container-padding-horizontal); } - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-container-item-padding-remove-left, - .uk-container-item-padding-remove-right { width: unquote('calc(100% + #{$container-padding-horizontal-s})') } - - .uk-container-item-padding-remove-left { margin-left: (-$container-padding-horizontal-s); } - .uk-container-item-padding-remove-right { margin-right: (-$container-padding-horizontal-s); } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-container-item-padding-remove-left, - .uk-container-item-padding-remove-right { width: unquote('calc(100% + #{$container-padding-horizontal-m})') } - - .uk-container-item-padding-remove-left { margin-left: (-$container-padding-horizontal-m); } - .uk-container-item-padding-remove-right { margin-right: (-$container-padding-horizontal-m); } - -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-container-misc)) {@include hook-container-misc();} - -// @mixin hook-container-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/countdown.scss b/docs/_sass/uikit/components/countdown.scss deleted file mode 100644 index b01209d711..0000000000 --- a/docs/_sass/uikit/components/countdown.scss +++ /dev/null @@ -1,131 +0,0 @@ -// Name: Countdown -// Description: Component to create countdown timers -// -// Component: `uk-countdown` -// -// Sub-objects: `uk-countdown-number` -// `uk-countdown-separator` -// `uk-countdown-label` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$countdown-number-line-height: 0.8 !default; -$countdown-number-font-size: 2rem !default; // 32px -$countdown-number-font-size-s: 4rem !default; // 64px -$countdown-number-font-size-m: 6rem !default; // 96px - -$countdown-separator-line-height: 1.6 !default; -$countdown-separator-font-size: 1rem !default; // 16px -$countdown-separator-font-size-s: 2rem !default; // 32px -$countdown-separator-font-size-m: 3rem !default; // 48px - - -/* ======================================================================== - Component: Countdown - ========================================================================== */ - -.uk-countdown { - @if(mixin-exists(hook-countdown)) {@include hook-countdown();} -} - - -/* Item - ========================================================================== */ - -.uk-countdown-number, -.uk-countdown-separator { - @if(mixin-exists(hook-countdown-item)) {@include hook-countdown-item();} -} - - -/* Number - ========================================================================== */ - - -/* - * 1. Make numbers all of the same size to prevent jumping. Must be supported by the font. - * 2. Style - */ - -.uk-countdown-number { - /* 1 */ - font-variant-numeric: tabular-nums; - /* 2 */ - font-size: $countdown-number-font-size; - line-height: $countdown-number-line-height; - @if(mixin-exists(hook-countdown-number)) {@include hook-countdown-number();} -} - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-countdown-number { font-size: $countdown-number-font-size-s; } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-countdown-number { font-size: $countdown-number-font-size-m; } - -} - - -/* Separator - ========================================================================== */ - -.uk-countdown-separator { - font-size: $countdown-separator-font-size; - line-height: $countdown-separator-line-height; - @if(mixin-exists(hook-countdown-separator)) {@include hook-countdown-separator();} -} - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-countdown-separator { font-size: $countdown-separator-font-size-s; } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-countdown-separator { font-size: $countdown-separator-font-size-m; } - -} - - -/* Label - ========================================================================== */ - -.uk-countdown-label { - @if(mixin-exists(hook-countdown-label)) {@include hook-countdown-label();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-countdown-misc)) {@include hook-countdown-misc();} - -// @mixin hook-countdown(){} -// @mixin hook-countdown-item(){} -// @mixin hook-countdown-number(){} -// @mixin hook-countdown-separator(){} -// @mixin hook-countdown-label(){} -// @mixin hook-countdown-misc(){} - - -// Inverse -// ======================================================================== - - - -// @mixin hook-inverse-countdown-item(){} -// @mixin hook-inverse-countdown-number(){} -// @mixin hook-inverse-countdown-separator(){} -// @mixin hook-inverse-countdown-label(){} diff --git a/docs/_sass/uikit/components/cover.scss b/docs/_sass/uikit/components/cover.scss deleted file mode 100644 index b44a6847d5..0000000000 --- a/docs/_sass/uikit/components/cover.scss +++ /dev/null @@ -1,57 +0,0 @@ -// Name: Cover -// Description: Utilities to let embedded content cover their container in a centered position -// -// Component: `uk-cover` -// -// Sub-object: `uk-cover-container` -// -// ======================================================================== - - -/* ======================================================================== - Component: Cover - ========================================================================== */ - -/* - * Works with iframes and embedded content - * 1. Reset responsiveness for embedded content - * 2. Center object - * Note: Percent values on the `top` property only works if this element - * is absolute positioned or if the container has a height - */ - -.uk-cover { - /* 1 */ - max-width: none; - /* 2 */ - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%,-50%); -} - -iframe.uk-cover { pointer-events: none; } - - -/* Container - ========================================================================== */ - -/* - * 1. Parent container which clips resized object - * 2. Needed if the child is positioned absolute. See note above - */ - -.uk-cover-container { - /* 1 */ - overflow: hidden; - /* 2 */ - position: relative; -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-cover-misc)) {@include hook-cover-misc();} - -// @mixin hook-cover-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/description-list.scss b/docs/_sass/uikit/components/description-list.scss deleted file mode 100644 index 6683286dfd..0000000000 --- a/docs/_sass/uikit/components/description-list.scss +++ /dev/null @@ -1,71 +0,0 @@ -// Name: Description list -// Description: Styles for description lists -// -// Component: `uk-description-list` -// -// Modifiers: `uk-description-list-divider` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$description-list-term-color: $global-emphasis-color !default; -$description-list-term-margin-top: $global-margin !default; - -$description-list-divider-term-margin-top: $global-margin !default; -$description-list-divider-term-border-width: $global-border-width !default; -$description-list-divider-term-border: $global-border !default; - - -/* ======================================================================== - Component: Description list - ========================================================================== */ - -/* - * Term - */ - -.uk-description-list > dt { - color: $description-list-term-color; - @if(mixin-exists(hook-description-list-term)) {@include hook-description-list-term();} -} - -.uk-description-list > dt:nth-child(n+2) { - margin-top: $description-list-term-margin-top; -} - -/* - * Description - */ - -.uk-description-list > dd { - @if(mixin-exists(hook-description-list-description)) {@include hook-description-list-description();} -} - - -/* Style modifier - ========================================================================== */ - -/* - * Line - */ - -.uk-description-list-divider > dt:nth-child(n+2) { - margin-top: $description-list-divider-term-margin-top; - padding-top: $description-list-divider-term-margin-top; - border-top: $description-list-divider-term-border-width solid $description-list-divider-term-border; - @if(mixin-exists(hook-description-list-divider-term)) {@include hook-description-list-divider-term();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-description-list-misc)) {@include hook-description-list-misc();} - -// @mixin hook-description-list-term(){} -// @mixin hook-description-list-description(){} -// @mixin hook-description-list-divider-term(){} -// @mixin hook-description-list-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/divider.scss b/docs/_sass/uikit/components/divider.scss deleted file mode 100644 index 104fcdb48d..0000000000 --- a/docs/_sass/uikit/components/divider.scss +++ /dev/null @@ -1,153 +0,0 @@ -// Name: Divider -// Description: Styles for dividers -// -// Component: `uk-divider-icon` -// `uk-divider-small` -// `uk-divider-vertical` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$divider-margin-vertical: $global-margin !default; - -$divider-icon-width: 50px !default; -$divider-icon-height: 20px !default; -$divider-icon-color: $global-border !default; -$divider-icon-line-top: 50% !default; -$divider-icon-line-width: 100% !default; -$divider-icon-line-border-width: $global-border-width !default; -$divider-icon-line-border: $global-border !default; - -$internal-divider-icon-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Ccircle%20fill%3D%22none%22%20stroke%3D%22#000%22%20stroke-width%3D%222%22%20cx%3D%2210%22%20cy%3D%2210%22%20r%3D%227%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; - -$divider-small-width: 100px !default; -$divider-small-border-width: $global-border-width !default; -$divider-small-border: $global-border !default; - -$divider-vertical-height: 100px !default; -$divider-vertical-border-width: $global-border-width !default; -$divider-vertical-border: $global-border !default; - - -/* ======================================================================== - Component: Divider - ========================================================================== */ - -/* - * 1. Reset default `hr` - * 2. Set margin if a `div` is used for semantical reason - */ - -[class*='uk-divider'] { - /* 1 */ - border: none; - /* 2 */ - margin-bottom: $divider-margin-vertical; -} - -/* Add margin if adjacent element */ -* + [class*='uk-divider'] { margin-top: $divider-margin-vertical; } - - -/* Icon - ========================================================================== */ - -.uk-divider-icon { - position: relative; - height: $divider-icon-height; - @include svg-fill($internal-divider-icon-image, "#000", $divider-icon-color); - background-repeat: no-repeat; - background-position: 50% 50%; - @if(mixin-exists(hook-divider-icon)) {@include hook-divider-icon();} -} - -.uk-divider-icon::before, -.uk-divider-icon::after { - content: ""; - position: absolute; - top: $divider-icon-line-top; - max-width: unquote('calc(50% - (#{$divider-icon-width} / 2))'); - border-bottom: $divider-icon-line-border-width solid $divider-icon-line-border; - @if(mixin-exists(hook-divider-icon-line)) {@include hook-divider-icon-line();} -} - -.uk-divider-icon::before { - right: unquote('calc(50% + (#{$divider-icon-width} / 2))'); - width: $divider-icon-line-width; - @if(mixin-exists(hook-divider-icon-line-left)) {@include hook-divider-icon-line-left();} -} - -.uk-divider-icon::after { - left: unquote('calc(50% + (#{$divider-icon-width} / 2))'); - width: $divider-icon-line-width; - @if(mixin-exists(hook-divider-icon-line-right)) {@include hook-divider-icon-line-right();} -} - - -/* Small - ========================================================================== */ - -/* - * 1. Fix height because of `inline-block` - * 2. Using ::after and inline-block to make `text-align` work - */ - -/* 1 */ -.uk-divider-small { line-height: 0; } - -/* 2 */ -.uk-divider-small::after { - content: ""; - display: inline-block; - width: $divider-small-width; - max-width: 100%; - border-top: $divider-small-border-width solid $divider-small-border; - vertical-align: top; - @if(mixin-exists(hook-divider-small)) {@include hook-divider-small();} -} - - -/* Vertical - ========================================================================== */ - -.uk-divider-vertical { - width: 1px; - height: $divider-vertical-height; - margin-left: auto; - margin-right: auto; - border-left: $divider-vertical-border-width solid $divider-vertical-border; - @if(mixin-exists(hook-divider-vertical)) {@include hook-divider-vertical();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-divider-misc)) {@include hook-divider-misc();} - -// @mixin hook-divider-icon(){} -// @mixin hook-divider-icon-line(){} -// @mixin hook-divider-icon-line-left(){} -// @mixin hook-divider-icon-line-right(){} -// @mixin hook-divider-small(){} -// @mixin hook-divider-vertical(){} -// @mixin hook-divider-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-divider-icon-color: $inverse-global-border !default; -$inverse-divider-icon-line-border: $inverse-global-border !default; -$inverse-divider-small-border: $inverse-global-border !default; -$inverse-divider-vertical-border: $inverse-global-border !default; - - - -// @mixin hook-inverse-divider-icon(){} -// @mixin hook-inverse-divider-icon-line(){} -// @mixin hook-inverse-divider-small(){} -// @mixin hook-inverse-divider-vertical(){} diff --git a/docs/_sass/uikit/components/dotnav.scss b/docs/_sass/uikit/components/dotnav.scss deleted file mode 100644 index f1f2a4029c..0000000000 --- a/docs/_sass/uikit/components/dotnav.scss +++ /dev/null @@ -1,157 +0,0 @@ -// Name: Dotnav -// Description: Component to create dot navigations -// -// Component: `uk-dotnav` -// -// Modifier: `uk-dotnav-vertical` -// -// States: `uk-active` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$dotnav-margin-horizontal: 12px !default; -$dotnav-margin-vertical: $dotnav-margin-horizontal !default; - -$dotnav-item-width: 10px !default; -$dotnav-item-height: $dotnav-item-width !default; -$dotnav-item-border-radius: 50% !default; - -$dotnav-item-background: rgba($global-color, 0.2) !default; -$dotnav-item-hover-background: rgba($global-color, 0.6) !default; -$dotnav-item-onclick-background: rgba($global-color, 0.2) !default; -$dotnav-item-active-background: rgba($global-color, 0.6) !default; - - -/* ======================================================================== - Component: Dotnav - ========================================================================== */ - -/* - * 1. Allow items to wrap into the next line - * 2. Reset list - * 3. Gutter - */ - -.uk-dotnav { - display: flex; - /* 1 */ - flex-wrap: wrap; - /* 2 */ - margin: 0; - padding: 0; - list-style: none; - /* 3 */ - margin-left: (-$dotnav-margin-horizontal); - @if(mixin-exists(hook-dotnav)) {@include hook-dotnav();} -} - -/* - * 1. Space is allocated solely based on content dimensions: 0 0 auto - * 2. Gutter - */ - -.uk-dotnav > * { - /* 1 */ - flex: none; - /* 2 */ - padding-left: $dotnav-margin-horizontal; -} - - -/* Items - ========================================================================== */ - -/* - * Items - * 1. Hide text if present - */ - -.uk-dotnav > * > * { - display: block; - box-sizing: border-box; - width: $dotnav-item-width; - height: $dotnav-item-height; - border-radius: $dotnav-item-border-radius; - background: $dotnav-item-background; - /* 1 */ - text-indent: 100%; - overflow: hidden; - white-space: nowrap; - @if(mixin-exists(hook-dotnav-item)) {@include hook-dotnav-item();} -} - -/* Hover + Focus */ -.uk-dotnav > * > :hover, -.uk-dotnav > * > :focus { - background-color: $dotnav-item-hover-background; - outline: none; - @if(mixin-exists(hook-dotnav-item-hover)) {@include hook-dotnav-item-hover();} -} - -/* OnClick */ -.uk-dotnav > * > :active { - background-color: $dotnav-item-onclick-background; - @if(mixin-exists(hook-dotnav-item-onclick)) {@include hook-dotnav-item-onclick();} -} - -/* Active */ -.uk-dotnav > .uk-active > * { - background-color: $dotnav-item-active-background; - @if(mixin-exists(hook-dotnav-item-active)) {@include hook-dotnav-item-active();} -} - - -/* Modifier: 'uk-dotnav-vertical' - ========================================================================== */ - -/* - * 1. Change direction - * 2. Gutter - */ - -.uk-dotnav-vertical { - /* 1 */ - flex-direction: column; - /* 2 */ - margin-left: 0; - margin-top: (-$dotnav-margin-vertical); -} - -/* 2 */ -.uk-dotnav-vertical > * { - padding-left: 0; - padding-top: $dotnav-margin-vertical; -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-dotnav-misc)) {@include hook-dotnav-misc();} - -// @mixin hook-dotnav(){} -// @mixin hook-dotnav-item(){} -// @mixin hook-dotnav-item-hover(){} -// @mixin hook-dotnav-item-onclick(){} -// @mixin hook-dotnav-item-active(){} -// @mixin hook-dotnav-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-dotnav-item-background: rgba($inverse-global-color, 0.5) !default; -$inverse-dotnav-item-hover-background: rgba($inverse-global-color, 0.9) !default; -$inverse-dotnav-item-onclick-background: rgba($inverse-global-color, 0.5) !default; -$inverse-dotnav-item-active-background: rgba($inverse-global-color, 0.9) !default; - - - -// @mixin hook-inverse-dotnav-item(){} -// @mixin hook-inverse-dotnav-item-hover(){} -// @mixin hook-inverse-dotnav-item-onclick(){} -// @mixin hook-inverse-dotnav-item-active(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/drop.scss b/docs/_sass/uikit/components/drop.scss deleted file mode 100644 index fb5e9e8c5a..0000000000 --- a/docs/_sass/uikit/components/drop.scss +++ /dev/null @@ -1,74 +0,0 @@ -// Name: Drop -// Description: Component to position any element next to any other element. -// -// Component: `uk-drop` -// -// Modifiers: `uk-drop-top-*` -// `uk-drop-bottom-*` -// `uk-drop-left-*` -// `uk-drop-right-*` -// `uk-drop-stack` -// `uk-drop-grid` -// -// States: `uk-open` -// -// Uses: Animation -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$drop-z-index: $global-z-index + 20 !default; -$drop-width: 300px !default; -$drop-margin: $global-margin !default; - - -/* ======================================================================== - Component: Drop - ========================================================================== */ - -/* - * 1. Hide by default - * 2. Set position - * 3. Set a default width - */ - -.uk-drop { - /* 1 */ - display: none; - /* 2 */ - position: absolute; - z-index: $drop-z-index; - /* 3 */ - box-sizing: border-box; - width: $drop-width; -} - -/* Show */ -.uk-drop.uk-open { display: block; } - - -/* Direction / Alignment modifiers - ========================================================================== */ - -/* Direction */ -[class*='uk-drop-top'] { margin-top: (-$drop-margin); } -[class*='uk-drop-bottom'] { margin-top: $drop-margin; } -[class*='uk-drop-left'] { margin-left: (-$drop-margin); } -[class*='uk-drop-right'] { margin-left: $drop-margin; } - - -/* Grid modifiers - ========================================================================== */ - -.uk-drop-stack .uk-drop-grid > * { width: 100% !important; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-drop-misc)) {@include hook-drop-misc();} - -// @mixin hook-drop-misc(){} diff --git a/docs/_sass/uikit/components/dropdown.scss b/docs/_sass/uikit/components/dropdown.scss deleted file mode 100644 index 25af1079d2..0000000000 --- a/docs/_sass/uikit/components/dropdown.scss +++ /dev/null @@ -1,153 +0,0 @@ -// Name: Dropdown -// Description: Component to create dropdown menus -// -// Component: `uk-dropdown` -// -// Adopted: `uk-dropdown-nav` -// -// Modifiers: `uk-dropdown-top-*` -// `uk-dropdown-bottom-*` -// `uk-dropdown-left-*` -// `uk-dropdown-right-*` -// `uk-dropdown-stack` -// `uk-dropdown-grid` -// -// States: `uk-active` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$dropdown-z-index: $global-z-index + 20 !default; -$dropdown-min-width: 200px !default; -$dropdown-padding: 15px !default; -$dropdown-background: $global-muted-background !default; -$dropdown-color: $global-color !default; -$dropdown-margin: $global-small-margin !default; - -$dropdown-nav-item-color: $global-muted-color !default; -$dropdown-nav-item-hover-color: $global-color !default; -$dropdown-nav-header-color: $global-emphasis-color !default; -$dropdown-nav-divider-border-width: $global-border-width !default; -$dropdown-nav-divider-border: $global-border !default; -$dropdown-nav-sublist-item-color: $global-muted-color !default; -$dropdown-nav-sublist-item-hover-color: $global-color !default; - - -/* ======================================================================== - Component: Dropdown - ========================================================================== */ - -/* - * 1. Hide by default - * 2. Set position - * 3. Set a default width - * 4. Style - */ - -.uk-dropdown { - /* 1 */ - display: none; - /* 2 */ - position: absolute; - z-index: $dropdown-z-index; - /* 3 */ - box-sizing: border-box; - min-width: $dropdown-min-width; - /* 4 */ - padding: $dropdown-padding; - background: $dropdown-background; - color: $dropdown-color; - @if(mixin-exists(hook-dropdown)) {@include hook-dropdown();} -} - -/* Show */ -.uk-dropdown.uk-open { display: block; } - - -/* Nav - * Adopts `uk-nav` - ========================================================================== */ - -.uk-dropdown-nav { - white-space: nowrap; - @if(mixin-exists(hook-dropdown-nav)) {@include hook-dropdown-nav();} -} - -/* - * Items - */ - -.uk-dropdown-nav > li > a { - color: $dropdown-nav-item-color; - @if(mixin-exists(hook-dropdown-nav-item)) {@include hook-dropdown-nav-item();} -} - -/* Hover + Focus + Active */ -.uk-dropdown-nav > li > a:hover, -.uk-dropdown-nav > li > a:focus, -.uk-dropdown-nav > li.uk-active > a { - color: $dropdown-nav-item-hover-color; - @if(mixin-exists(hook-dropdown-nav-item-hover)) {@include hook-dropdown-nav-item-hover();} -} - -/* - * Header - */ - -.uk-dropdown-nav .uk-nav-header { - color: $dropdown-nav-header-color; - @if(mixin-exists(hook-dropdown-nav-header)) {@include hook-dropdown-nav-header();} -} - -/* - * Divider - */ - -.uk-dropdown-nav .uk-nav-divider { - border-top: $dropdown-nav-divider-border-width solid $dropdown-nav-divider-border; - @if(mixin-exists(hook-dropdown-nav-divider)) {@include hook-dropdown-nav-divider();} -} - -/* - * Sublists - */ - -.uk-dropdown-nav .uk-nav-sub a { color: $dropdown-nav-sublist-item-color; } - -.uk-dropdown-nav .uk-nav-sub a:hover, -.uk-dropdown-nav .uk-nav-sub a:focus, -.uk-dropdown-nav .uk-nav-sub li.uk-active > a { color: $dropdown-nav-sublist-item-hover-color; } - - -/* Direction / Alignment modifiers - ========================================================================== */ - -/* Direction */ -[class*='uk-dropdown-top'] { margin-top: (-$dropdown-margin); } -[class*='uk-dropdown-bottom'] { margin-top: $dropdown-margin; } -[class*='uk-dropdown-left'] { margin-left: (-$dropdown-margin); } -[class*='uk-dropdown-right'] { margin-left: $dropdown-margin; } - - -/* Grid modifiers - ========================================================================== */ - -.uk-dropdown-stack .uk-dropdown-grid > * { width: 100% !important; } - - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-dropdown-misc)) {@include hook-dropdown-misc();} - -// @mixin hook-dropdown(){} -// @mixin hook-dropdown-nav(){} -// @mixin hook-dropdown-nav-item(){} -// @mixin hook-dropdown-nav-item-hover(){} -// @mixin hook-dropdown-nav-header(){} -// @mixin hook-dropdown-nav-divider(){} -// @mixin hook-dropdown-misc(){} diff --git a/docs/_sass/uikit/components/flex.scss b/docs/_sass/uikit/components/flex.scss deleted file mode 100644 index 1301fc4335..0000000000 --- a/docs/_sass/uikit/components/flex.scss +++ /dev/null @@ -1,209 +0,0 @@ -// Name: Flex -// Description: Utilities for layouts based on flexbox -// -// Component: `uk-flex-*` -// -// ======================================================================== - - -/* ======================================================================== - Component: Flex - ========================================================================== */ - -.uk-flex { display: flex; } -.uk-flex-inline { display: inline-flex; } - -/* - * Remove pseudo elements created by micro clearfix as precaution - */ - -.uk-flex::before, -.uk-flex::after, -.uk-flex-inline::before, -.uk-flex-inline::after { display: none; } - - -/* Alignment - ========================================================================== */ - -/* - * Align items along the main axis of the current line of the flex container - * Row: Horizontal - */ - -// Default -.uk-flex-left { justify-content: flex-start; } -.uk-flex-center { justify-content: center; } -.uk-flex-right { justify-content: flex-end; } -.uk-flex-between { justify-content: space-between; } -.uk-flex-around { justify-content: space-around; } - - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-flex-left\@s { justify-content: flex-start; } - .uk-flex-center\@s { justify-content: center; } - .uk-flex-right\@s { justify-content: flex-end; } - .uk-flex-between\@s { justify-content: space-between; } - .uk-flex-around\@s { justify-content: space-around; } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-flex-left\@m { justify-content: flex-start; } - .uk-flex-center\@m { justify-content: center; } - .uk-flex-right\@m { justify-content: flex-end; } - .uk-flex-between\@m { justify-content: space-between; } - .uk-flex-around\@m { justify-content: space-around; } - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-flex-left\@l { justify-content: flex-start; } - .uk-flex-center\@l { justify-content: center; } - .uk-flex-right\@l { justify-content: flex-end; } - .uk-flex-between\@l { justify-content: space-between; } - .uk-flex-around\@l { justify-content: space-around; } - -} - -/* Large screen and bigger */ -@media (min-width: $breakpoint-xlarge) { - - .uk-flex-left\@xl { justify-content: flex-start; } - .uk-flex-center\@xl { justify-content: center; } - .uk-flex-right\@xl { justify-content: flex-end; } - .uk-flex-between\@xl { justify-content: space-between; } - .uk-flex-around\@xl { justify-content: space-around; } - -} - -/* - * Align items in the cross axis of the current line of the flex container - * Row: Vertical - */ - -// Default -.uk-flex-stretch { align-items: stretch; } -.uk-flex-top { align-items: flex-start; } -.uk-flex-middle { align-items: center; } -.uk-flex-bottom { align-items: flex-end; } - - -/* Direction - ========================================================================== */ - -// Default -.uk-flex-row { flex-direction: row; } -.uk-flex-row-reverse { flex-direction: row-reverse; } -.uk-flex-column { flex-direction: column; } -.uk-flex-column-reverse { flex-direction: column-reverse; } - - -/* Wrap - ========================================================================== */ - -// Default -.uk-flex-nowrap { flex-wrap: nowrap; } -.uk-flex-wrap { flex-wrap: wrap; } -.uk-flex-wrap-reverse { flex-wrap: wrap-reverse; } - -/* - * Aligns items within the flex container when there is extra space in the cross-axis - * Only works if there is more than one line of flex items - */ - -// Default -.uk-flex-wrap-stretch { align-content: stretch; } -.uk-flex-wrap-top { align-content: flex-start; } -.uk-flex-wrap-middle { align-content: center; } -.uk-flex-wrap-bottom { align-content: flex-end; } -.uk-flex-wrap-between { align-content: space-between; } -.uk-flex-wrap-around { align-content: space-around; } - - -/* Item ordering - ========================================================================== */ - -/* - * Default is 0 - */ - -.uk-flex-first { order: -1;} -.uk-flex-last { order: 99;} - - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-flex-first\@s { order: -1; } - .uk-flex-last\@s { order: 99; } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-flex-first\@m { order: -1; } - .uk-flex-last\@m { order: 99; } - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-flex-first\@l { order: -1; } - .uk-flex-last\@l { order: 99; } - -} - -/* Large screen and bigger */ -@media (min-width: $breakpoint-xlarge) { - - .uk-flex-first\@xl { order: -1; } - .uk-flex-last\@xl { order: 99; } - -} - - -/* Item dimensions - ========================================================================== */ - -/* - * Initial: 0 1 auto - * Content dimensions, but shrinks - */ - -/* - * No Flex: 0 0 auto - * Content dimensions - */ - -.uk-flex-none { flex: none; } - -/* - * Relative Flex: 1 1 auto - * Space is allocated considering content - */ - -.uk-flex-auto { flex: auto; } - -/* - * Absolute Flex: 1 1 0% - * Space is allocated solely based on flex - */ - -.uk-flex-1 { flex: 1; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-flex-misc)) {@include hook-flex-misc();} - -// @mixin hook-flex-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/form-range.scss b/docs/_sass/uikit/components/form-range.scss deleted file mode 100644 index 328f08fc3e..0000000000 --- a/docs/_sass/uikit/components/form-range.scss +++ /dev/null @@ -1,186 +0,0 @@ -// Name: Form Range -// Description: Styles for the range input type -// -// Component: `uk-range` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$form-range-thumb-height: 15px !default; -$form-range-thumb-width: $form-range-thumb-height !default; -$form-range-thumb-border-radius: 500px !default; -$form-range-thumb-background: $global-color !default; - -$form-range-track-height: 3px !default; -$form-range-track-background: darken($global-muted-background, 5%) !default; -$form-range-track-focus-background: darken($form-range-track-background, 5%) !default; - - -/* ======================================================================== - Component: Form Range - ========================================================================== */ - -/* - * 1. Normalize and defaults - * 2. Prevent content overflow if a fixed width is used - * 3. Take the full width - * 4. Remove default style - * 5. Remove white background in Chrome - * 6. Remove padding in IE11 - */ - -.uk-range { - /* 1 */ - box-sizing: border-box; - margin: 0; - vertical-align: middle; - /* 2 */ - max-width: 100%; - /* 3 */ - width: 100%; - /* 4 */ - -webkit-appearance: none; - /* 5 */ - background: transparent; - /* 6 */ - padding: 0; - @if(mixin-exists(hook-form-range)) {@include hook-form-range();} -} - -/* Focus */ -.uk-range:focus { outline: none; } -.uk-range::-moz-focus-outer { border: none; } - -/* IE11 Reset */ -.uk-range::-ms-track { - height: $form-range-thumb-height; - background: transparent; - border-color: transparent; - color: transparent; -} - -/* - * Improves consistency of cursor style for clickable elements - */ - -.uk-range:not(:disabled)::-webkit-slider-thumb { cursor: pointer; } -.uk-range:not(:disabled)::-moz-range-thumb { cursor: pointer; } -.uk-range:not(:disabled)::-ms-thumb { cursor: pointer; } - - -/* Thumb - ========================================================================== */ - -/* - * 1. Reset - * 2. Style - */ - -/* Webkit */ -.uk-range::-webkit-slider-thumb { - /* 1 */ - -webkit-appearance: none; - margin-top: (floor($form-range-thumb-height / 2) * -1); - /* 2 */ - height: $form-range-thumb-height; - width: $form-range-thumb-width; - border-radius: $form-range-thumb-border-radius; - background: $form-range-thumb-background; - @if(mixin-exists(hook-form-range-thumb)) {@include hook-form-range-thumb();} -} - -/* Firefox */ -.uk-range::-moz-range-thumb { - /* 1 */ - border: none; - /* 2 */ - height: $form-range-thumb-height; - width: $form-range-thumb-width; - border-radius: $form-range-thumb-border-radius; - background: $form-range-thumb-background; - @if(mixin-exists(hook-form-range-thumb)) {@include hook-form-range-thumb();} -} - -/* Edge */ -.uk-range::-ms-thumb { - /* 1 */ - margin-top: 0; -} - -/* IE11 */ -.uk-range::-ms-thumb { - /* 1 */ - border: none; - /* 2 */ - height: $form-range-thumb-height; - width: $form-range-thumb-width; - border-radius: $form-range-thumb-border-radius; - background: $form-range-thumb-background; - @if(mixin-exists(hook-form-range-thumb)) {@include hook-form-range-thumb();} -} - -/* Edge + IE11 */ -.uk-range::-ms-tooltip { display: none; } - - -/* Track - ========================================================================== */ - -/* - * 1. Safari doesn't have a focus state. Using active instead. - */ - -/* Webkit */ -.uk-range::-webkit-slider-runnable-track { - height: $form-range-track-height; - background: $form-range-track-background; - @if(mixin-exists(hook-form-range-track)) {@include hook-form-range-track();} -} - -.uk-range:focus::-webkit-slider-runnable-track, -/* 1 */ -.uk-range:active::-webkit-slider-runnable-track { - background: $form-range-track-focus-background; - @if(mixin-exists(hook-form-range-track-focus)) {@include hook-form-range-track-focus();} -} - -/* Firefox */ -.uk-range::-moz-range-track { - height: $form-range-track-height; - background: $form-range-track-background; - @if(mixin-exists(hook-form-range-track)) {@include hook-form-range-track();} -} - -.uk-range:focus::-moz-range-track { - background: $form-range-track-focus-background; - @if(mixin-exists(hook-form-range-track-focus)) {@include hook-form-range-track-focus();} -} - -/* Edge */ -.uk-range::-ms-fill-lower, -.uk-range::-ms-fill-upper { - height: $form-range-track-height; - background: $form-range-track-background; - @if(mixin-exists(hook-form-range-track)) {@include hook-form-range-track();} -} - -.uk-range:focus::-ms-fill-lower, -.uk-range:focus::-ms-fill-upper { - background: $form-range-track-focus-background; - @if(mixin-exists(hook-form-range-track-focus)) {@include hook-form-range-track-focus();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-form-range-misc)) {@include hook-form-range-misc();} - -// @mixin hook-form-range(){} -// @mixin hook-form-range-thumb(){} -// @mixin hook-form-range-track(){} -// @mixin hook-form-range-track-focus(){} -// @mixin hook-form-range-misc(){} diff --git a/docs/_sass/uikit/components/form.scss b/docs/_sass/uikit/components/form.scss deleted file mode 100644 index 08fe9920ae..0000000000 --- a/docs/_sass/uikit/components/form.scss +++ /dev/null @@ -1,811 +0,0 @@ -// Name: Form -// Description: Styles for forms -// -// Component: `uk-form-*` -// `uk-input` -// `uk-select` -// `uk-textarea` -// `uk-radio` -// `uk-checkbox` -// `uk-legend` -// `uk-fieldset` -// -// Sub-objects: `uk-form-custom` -// `uk-form-stacked` -// `uk-form-horizontal` -// `uk-form-label` -// `uk-form-controls` -// `uk-form-icon` -// `uk-form-icon-flip` -// -// Modifiers: `uk-form-small` -// `uk-form-large` -// `uk-form-danger` -// `uk-form-success` -// `uk-form-blank` -// `uk-form-width-xsmall` -// `uk-form-width-small` -// `uk-form-width-medium` -// `uk-form-width-large` -// `uk-form-controls-text` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$form-height: $global-control-height !default; -$form-line-height: $form-height !default; -$form-padding-horizontal: 10px !default; -$form-padding-vertical: round($form-padding-horizontal * 0.6) !default; - -$form-background: $global-muted-background !default; -$form-color: $global-color !default; - -$form-focus-background: darken($form-background, 5%) !default; -$form-focus-color: $global-color !default; - -$form-disabled-background: $global-muted-background !default; -$form-disabled-color: $global-muted-color !default; - -$form-placeholder-color: $global-muted-color !default; - -$form-small-height: $global-control-small-height !default; -$form-small-padding-horizontal: 8px !default; -$form-small-padding-vertical: round($form-small-padding-horizontal * 0.6) !default; -$form-small-line-height: $form-small-height !default; -$form-small-font-size: $global-small-font-size !default; - -$form-large-height: $global-control-large-height !default; -$form-large-padding-horizontal: 12px !default; -$form-large-padding-vertical: round($form-large-padding-horizontal * 0.6) !default; -$form-large-line-height: $form-large-height !default; -$form-large-font-size: $global-medium-font-size !default; - -$form-danger-color: $global-danger-background !default; -$form-success-color: $global-success-background !default; - -$form-width-xsmall: 50px !default; -$form-width-small: 130px !default; -$form-width-medium: 200px !default; -$form-width-large: 500px !default; - -$form-select-padding-right: 20px !default; -$form-select-icon-color: $global-color !default; -$form-select-option-color: #444 !default; -$form-select-disabled-icon-color: $global-muted-color !default; - -$form-datalist-padding-right: 20px !default; -$form-datalist-icon-color: $global-color !default; - -$form-radio-size: 16px !default; -$form-radio-margin-top: -4px !default; -$form-radio-background: darken($global-muted-background, 5%) !default; - -$form-radio-focus-background: darken($form-radio-background, 5%) !default; - -$form-radio-checked-background: $global-primary-background !default; -$form-radio-checked-icon-color: $global-inverse-color !default; - -$form-radio-checked-focus-background: darken($global-primary-background, 10%) !default; - -$form-radio-disabled-background: $global-muted-background !default; -$form-radio-disabled-icon-color: $global-muted-color !default; - -$form-legend-font-size: $global-large-font-size !default; -$form-legend-line-height: 1.4 !default; - -$form-stacked-margin-bottom: $global-small-margin !default; - -$form-horizontal-label-width: 200px !default; -$form-horizontal-label-margin-top: 7px !default; -$form-horizontal-controls-margin-left: 215px !default; -$form-horizontal-controls-text-padding-top: 7px !default; - -$form-icon-width: $form-height !default; -$form-icon-color: $global-muted-color !default; -$form-icon-hover-color: $global-color !default; - -$internal-form-select-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2224%22%20height%3D%2216%22%20viewBox%3D%220%200%2024%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%201%209%206%2015%206%22%20%2F%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%2013%209%208%2015%208%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; -$internal-form-datalist-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2224%22%20height%3D%2216%22%20viewBox%3D%220%200%2024%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%2012%208%206%2016%206%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; -$internal-form-radio-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Ccircle%20fill%3D%22#000%22%20cx%3D%228%22%20cy%3D%228%22%20r%3D%222%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$internal-form-checkbox-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2211%22%20viewBox%3D%220%200%2014%2011%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%201%205%207.5%202%205%201%205.5%205%2010%2013%201.5%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; -$internal-form-checkbox-indeterminate-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Crect%20fill%3D%22#000%22%20x%3D%223%22%20y%3D%228%22%20width%3D%2210%22%20height%3D%221%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; - - -/* ======================================================================== - Component: Form - ========================================================================== */ - -/* - * 1. Define consistent box sizing. - * Default is `content-box` with following exceptions set to `border-box` - * `select`, `input[type="checkbox"]` and `input[type="radio"]` - * `input[type="search"]` in Chrome, Safari and Opera - * `input[type="color"]` in Firefox - * 2. Address margins set differently in Firefox/IE and Chrome/Safari/Opera. - * 3. Remove `border-radius` in iOS. - * 4. Change font properties to `inherit` in all browsers. - */ - -.uk-input, -.uk-select, -.uk-textarea, -.uk-radio, -.uk-checkbox { - /* 1 */ - box-sizing: border-box; - /* 2 */ - margin: 0; - /* 3 */ - border-radius: 0; - /* 4 */ - font: inherit; -} - -/* - * Show the overflow in Edge. - */ - -.uk-input { overflow: visible; } - -/* - * Remove the inheritance of text transform in Firefox. - */ - -.uk-select { text-transform: none; } - -/* - * 1. Change font properties to `inherit` in all browsers - * 2. Don't inherit the `font-weight` and use `bold` instead. - * NOTE: Both declarations don't work in Chrome, Safari and Opera. - */ - -.uk-select optgroup { - /* 1 */ - font: inherit; - /* 2 */ - font-weight: bold; -} - -/* - * Remove the default vertical scrollbar in IE 10+. - */ - -.uk-textarea { overflow: auto; } - -/* - * Remove the inner padding and cancel buttons in Chrome on OS X and Safari on OS X. - */ - -.uk-input[type="search"]::-webkit-search-cancel-button, -.uk-input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; } - - -/* - * Correct the cursor style of increment and decrement buttons in Chrome. - */ - -.uk-input[type="number"]::-webkit-inner-spin-button, -.uk-input[type="number"]::-webkit-outer-spin-button { height: auto; } - -/* - * Removes placeholder transparency in Firefox. - */ - -.uk-input::-moz-placeholder, -.uk-textarea::-moz-placeholder { opacity: 1; } - -/* - * Improves consistency of cursor style for clickable elements - */ - -.uk-radio:not(:disabled), -.uk-checkbox:not(:disabled) { cursor: pointer; } - -/* - * Define consistent border, margin, and padding. - */ - -.uk-fieldset { - border: none; - margin: 0; - padding: 0; -} - - -/* Input, select and textarea - * Allowed: `text`, `password`, `datetime`, `datetime-local`, `date`, `month`, - `time`, `week`, `number`, `email`, `url`, `search`, `tel`, `color` - * Disallowed: `range`, `radio`, `checkbox`, `file`, `submit`, `reset` and `image` - ========================================================================== */ - -/* - * Remove default style in iOS. - */ - -.uk-input, -.uk-textarea { -webkit-appearance: none; } - -/* - * 1. Prevent content overflow if a fixed width is used - * 2. Take the full width - * 3. Reset default - * 4. Style - */ - -.uk-input, -.uk-select, -.uk-textarea { - /* 1 */ - max-width: 100%; - /* 2 */ - width: 100%; - /* 3 */ - border: 0 none; - /* 4 */ - padding: 0 $form-padding-horizontal; - background: $form-background; - color: $form-color; - @if(mixin-exists(hook-form)) {@include hook-form();} -} - -/* - * Single-line - * 1. Allow any element to look like an `input` or `select` element - * 2. Make sure line-height is not larger than height - * Also needed to center the text vertically - */ - -.uk-input, -.uk-select:not([multiple]):not([size]) { - height: $form-height; - vertical-align: middle; - /* 1 */ - display: inline-block; - @if(mixin-exists(hook-form-single-line)) {@include hook-form-single-line();} -} - -/* 2 */ -.uk-input:not(input), -.uk-select:not(select) { line-height: $form-line-height; } - -/* - * Multi-line - */ - -.uk-select[multiple], -.uk-select[size], -.uk-textarea { - padding-top: $form-padding-vertical; - padding-bottom: $form-padding-vertical; - vertical-align: top; - @if(mixin-exists(hook-form-multi-line)) {@include hook-form-multi-line();} -} - -.uk-select[multiple], -.uk-select[size] { resize: vertical; } - -/* Focus */ -.uk-input:focus, -.uk-select:focus, -.uk-textarea:focus { - outline: none; - background-color: $form-focus-background; - color: $form-focus-color; - @if(mixin-exists(hook-form-focus)) {@include hook-form-focus();} -} - -/* Disabled */ -.uk-input:disabled, -.uk-select:disabled, -.uk-textarea:disabled { - background-color: $form-disabled-background; - color: $form-disabled-color; - @if(mixin-exists(hook-form-disabled)) {@include hook-form-disabled();} -} - -/* - * Placeholder - */ - -.uk-input::-ms-input-placeholder { color: $form-placeholder-color !important; } -.uk-input::placeholder { color: $form-placeholder-color; } - -.uk-textarea::-ms-input-placeholder { color: $form-placeholder-color !important; } -.uk-textarea::placeholder { color: $form-placeholder-color; } - - -/* Style modifier (`uk-input`, `uk-select` and `uk-textarea`) - ========================================================================== */ - -/* - * Small - */ - -.uk-form-small { font-size: $form-small-font-size; } - -/* Single-line */ -.uk-form-small:not(textarea):not([multiple]):not([size]) { - height: $form-small-height; - padding-left: $form-small-padding-horizontal; - padding-right: $form-small-padding-horizontal; -} - -/* Multi-line */ -textarea.uk-form-small, -[multiple].uk-form-small, -[size].uk-form-small { padding: $form-small-padding-vertical $form-small-padding-horizontal; } - -.uk-form-small:not(select):not(input):not(textarea) { line-height: $form-small-line-height; } - -/* - * Large - */ - -.uk-form-large { font-size: $form-large-font-size; } - -/* Single-line */ -.uk-form-large:not(textarea):not([multiple]):not([size]) { - height: $form-large-height; - padding-left: $form-large-padding-horizontal; - padding-right: $form-large-padding-horizontal; -} - -/* Multi-line */ -textarea.uk-form-large, -[multiple].uk-form-large, -[size].uk-form-large { padding: $form-large-padding-vertical $form-large-padding-horizontal; } - -.uk-form-large:not(select):not(input):not(textarea) { line-height: $form-large-line-height; } - - -/* Style modifier (`uk-input`, `uk-select` and `uk-textarea`) - ========================================================================== */ - -/* - * Error - */ - -.uk-form-danger, -.uk-form-danger:focus { - color: $form-danger-color; - @if(mixin-exists(hook-form-danger)) {@include hook-form-danger();} -} - -/* - * Success - */ - -.uk-form-success, -.uk-form-success:focus { - color: $form-success-color; - @if(mixin-exists(hook-form-success)) {@include hook-form-success();} -} - -/* - * Blank - */ - -.uk-form-blank { - background: none; - @if(mixin-exists(hook-form-blank)) {@include hook-form-blank();} -} - -.uk-form-blank:focus { - @if(mixin-exists(hook-form-blank-focus)) {@include hook-form-blank-focus();} -} - - -/* Width modifiers (`uk-input`, `uk-select` and `uk-textarea`) - ========================================================================== */ - -/* - * Fixed widths - * Different widths for mini sized `input` and `select` elements - */ - -input.uk-form-width-xsmall { width: $form-width-xsmall; } - -select.uk-form-width-xsmall { width: ($form-width-xsmall + 25px); } - -.uk-form-width-small { width: $form-width-small; } - -.uk-form-width-medium { width: $form-width-medium; } - -.uk-form-width-large { width: $form-width-large; } - - -/* Select - ========================================================================== */ - -/* - * 1. Remove default style. Also works in Firefox - * 2. Style - * 3. Remove default style in IE 10/11 - * 4. Set `color` for options in the select dropdown, because the inherited `color` might be too light. - */ - -.uk-select:not([multiple]):not([size]) { - /* 1 */ - -webkit-appearance: none; - -moz-appearance: none; - /* 2 */ - padding-right: $form-select-padding-right; - @include svg-fill($internal-form-select-image, "#000", $form-select-icon-color); - background-repeat: no-repeat; - background-position: 100% 50%; -} - -/* 3 */ -.uk-select:not([multiple]):not([size])::-ms-expand { display: none; } - -/* 4 */ -.uk-select:not([multiple]):not([size]) option { color: $form-select-option-color; } - -/* - * Disabled - */ - -.uk-select:not([multiple]):not([size]):disabled { @include svg-fill($internal-form-select-image, "#000", $form-select-disabled-icon-color); } - - -/* Datalist - ========================================================================== */ - -/* - * 1. Remove default style in Chrome - */ - - .uk-input[list] { - padding-right: $form-datalist-padding-right; - background-repeat: no-repeat; - background-position: 100% 50%; -} - -.uk-input[list]:hover, -.uk-input[list]:focus { @include svg-fill($internal-form-datalist-image, "#000", $form-datalist-icon-color); } - -/* 1 */ -.uk-input[list]::-webkit-calendar-picker-indicator { display: none !important; } - - -/* Radio and checkbox - * Note: Does not work in IE11 - ========================================================================== */ - -/* - * 1. Style - * 2. Make box more robust so it clips the child element - * 3. Vertical alignment - * 4. Remove default style - * 5. Fix black background on iOS - * 6. Center icons - */ - -.uk-radio, -.uk-checkbox { - /* 1 */ - display: inline-block; - height: $form-radio-size; - width: $form-radio-size; - /* 2 */ - overflow: hidden; - /* 3 */ - margin-top: $form-radio-margin-top; - vertical-align: middle; - /* 4 */ - -webkit-appearance: none; - -moz-appearance: none; - /* 5 */ - background-color: $form-radio-background; - /* 6 */ - background-repeat: no-repeat; - background-position: 50% 50%; - @if(mixin-exists(hook-form-radio)) {@include hook-form-radio();} -} - -.uk-radio { border-radius: 50%; } - -/* Focus */ -.uk-radio:focus, -.uk-checkbox:focus { - background-color: $form-radio-focus-background; - outline: none; - @if(mixin-exists(hook-form-radio-focus)) {@include hook-form-radio-focus();} -} - -/* - * Checked - */ - -.uk-radio:checked, -.uk-checkbox:checked, -.uk-checkbox:indeterminate { - background-color: $form-radio-checked-background; - @if(mixin-exists(hook-form-radio-checked)) {@include hook-form-radio-checked();} -} - -/* Focus */ -.uk-radio:checked:focus, -.uk-checkbox:checked:focus, -.uk-checkbox:indeterminate:focus { - background-color: $form-radio-checked-focus-background; - @if(mixin-exists(hook-form-radio-checked-focus)) {@include hook-form-radio-checked-focus();} -} - -/* - * Icons - */ - -.uk-radio:checked { @include svg-fill($internal-form-radio-image, "#000", $form-radio-checked-icon-color); } -.uk-checkbox:checked { @include svg-fill($internal-form-checkbox-image, "#000", $form-radio-checked-icon-color); } -.uk-checkbox:indeterminate { @include svg-fill($internal-form-checkbox-indeterminate-image, "#000", $form-radio-checked-icon-color); } - -/* - * Disabled - */ - -.uk-radio:disabled, -.uk-checkbox:disabled { - background-color: $form-radio-disabled-background; - @if(mixin-exists(hook-form-radio-disabled)) {@include hook-form-radio-disabled();} -} - -.uk-radio:disabled:checked { @include svg-fill($internal-form-radio-image, "#000", $form-radio-disabled-icon-color); } -.uk-checkbox:disabled:checked { @include svg-fill($internal-form-checkbox-image, "#000", $form-radio-disabled-icon-color); } -.uk-checkbox:disabled:indeterminate { @include svg-fill($internal-form-checkbox-indeterminate-image, "#000", $form-radio-disabled-icon-color); } - - -/* Legend - ========================================================================== */ - -/* - * Legend - * 1. Behave like block element - * 2. Correct the color inheritance from `fieldset` elements in IE. - * 3. Remove padding so people aren't caught out if they zero out fieldsets. - * 4. Style - */ - -.uk-legend { - /* 1 */ - width: 100%; - /* 2 */ - color: inherit; - /* 3 */ - padding: 0; - /* 4 */ - font-size: $form-legend-font-size; - line-height: $form-legend-line-height; - @if(mixin-exists(hook-form-legend)) {@include hook-form-legend();} -} - - -/* Custom controls - ========================================================================== */ - -/* - * 1. Container fits its content - * 2. Create position context - * 3. Prevent content overflow - * 4. Behave like most inline-block elements - */ - -.uk-form-custom { - /* 1 */ - display: inline-block; - /* 2 */ - position: relative; - /* 3 */ - max-width: 100%; - /* 4 */ - vertical-align: middle; -} - -/* - * 1. Position and resize the form control to always cover its container - * 2. Required for Firefox for positioning to the left - * 3. Required for Webkit to make `height` work - * 4. Hide controle and show cursor - * 5. Needed for the cursor - * 6. Clip height caused by 5. Needed for Webkit only - */ - -.uk-form-custom select, -.uk-form-custom input[type="file"] { - /* 1 */ - position: absolute; - top: 0; - z-index: 1; - width: 100%; - height: 100%; - /* 2 */ - left: 0; - /* 3 */ - -webkit-appearance: none; - /* 4 */ - opacity: 0; - cursor: pointer; -} - -.uk-form-custom input[type="file"] { - /* 5 */ - font-size: 500px; - /* 6 */ - overflow: hidden; -} - - -/* Label - ========================================================================== */ - -.uk-form-label { - @if(mixin-exists(hook-form-label)) {@include hook-form-label();} -} - - -/* Layout - ========================================================================== */ - -/* - * Stacked - */ - -.uk-form-stacked .uk-form-label { - display: block; - margin-bottom: $form-stacked-margin-bottom; - @if(mixin-exists(hook-form-stacked-label)) {@include hook-form-stacked-label();} -} - -/* - * Horizontal - */ - -/* Tablet portrait and smaller */ -@media (max-width: $breakpoint-small-max) { - - /* Behave like `uk-form-stacked` */ - .uk-form-horizontal .uk-form-label { - display: block; - margin-bottom: $form-stacked-margin-bottom; - @if(mixin-exists(hook-form-stacked-label)) {@include hook-form-stacked-label();} - } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-form-horizontal .uk-form-label { - width: $form-horizontal-label-width; - margin-top: $form-horizontal-label-margin-top; - float: left; - @if(mixin-exists(hook-form-horizontal-label)) {@include hook-form-horizontal-label();} - } - - .uk-form-horizontal .uk-form-controls { margin-left: $form-horizontal-controls-margin-left; } - - /* Better vertical alignment if controls are checkboxes and radio buttons with text */ - .uk-form-horizontal .uk-form-controls-text { padding-top: $form-horizontal-controls-text-padding-top; } - -} - - -/* Icons - ========================================================================== */ - -/* - * 1. Set position - * 2. Set width - * 3. Center icon vertically and horizontally - * 4. Style - */ - -.uk-form-icon { - /* 1 */ - position: absolute; - top: 0; - bottom: 0; - left: 0; - /* 2 */ - width: $form-icon-width; - /* 3 */ - display: inline-flex; - justify-content: center; - align-items: center; - /* 4 */ - color: $form-icon-color; -} - -/* - * Required for `a`. - */ - -.uk-form-icon:hover { color: $form-icon-hover-color; } - -/* - * Make `input` element clickable through icon, e.g. if it's a `span` - */ - -.uk-form-icon:not(a):not(button):not(input) { pointer-events: none; } - -/* - * Input padding - */ - -.uk-form-icon:not(.uk-form-icon-flip) ~ .uk-input { padding-left: $form-icon-width !important; } - -/* - * Position modifier - */ - -.uk-form-icon-flip { - right: 0; - left: auto; -} - -.uk-form-icon-flip ~ .uk-input { padding-right: $form-icon-width !important; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-form-misc)) {@include hook-form-misc();} - -// @mixin hook-form(){} -// @mixin hook-form-single-line(){} -// @mixin hook-form-multi-line(){} -// @mixin hook-form-focus(){} -// @mixin hook-form-disabled(){} -// @mixin hook-form-danger(){} -// @mixin hook-form-success(){} -// @mixin hook-form-blank(){} -// @mixin hook-form-blank-focus(){} -// @mixin hook-form-radio(){} -// @mixin hook-form-radio-focus(){} -// @mixin hook-form-radio-checked(){} -// @mixin hook-form-radio-checked-focus(){} -// @mixin hook-form-radio-disabled(){} -// @mixin hook-form-legend(){} -// @mixin hook-form-label(){} -// @mixin hook-form-stacked-label(){} -// @mixin hook-form-horizontal-label(){} -// @mixin hook-form-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-form-background: $inverse-global-muted-background !default; -$inverse-form-color: $inverse-global-color !default; -$inverse-form-focus-background: fadein($inverse-form-background, 5%) !default; -$inverse-form-focus-color: $inverse-global-color !default; -$inverse-form-placeholder-color: $inverse-global-muted-color !default; - -$inverse-form-select-icon-color: $inverse-global-color !default; - -$inverse-form-datalist-icon-color: $inverse-global-color !default; - -$inverse-form-radio-background: $inverse-global-muted-background !default; - -$inverse-form-radio-focus-background: fadein($inverse-form-radio-background, 5%) !default; - -$inverse-form-radio-checked-background: $inverse-global-primary-background !default; -$inverse-form-radio-checked-icon-color: $inverse-global-inverse-color !default; - -$inverse-form-radio-checked-focus-background: fadein($inverse-global-primary-background, 10%) !default; - -$inverse-form-icon-color: $inverse-global-muted-color !default; -$inverse-form-icon-hover-color: $inverse-global-color !default; - - - -// @mixin hook-inverse-form(){} -// @mixin hook-inverse-form-focus(){} -// @mixin hook-inverse-form-radio(){} -// @mixin hook-inverse-form-radio-focus(){} -// @mixin hook-inverse-form-radio-checked(){} -// @mixin hook-inverse-form-radio-checked-focus(){} -// @mixin hook-inverse-form-label(){} diff --git a/docs/_sass/uikit/components/grid-masonry.scss b/docs/_sass/uikit/components/grid-masonry.scss deleted file mode 100644 index 935ea251e1..0000000000 --- a/docs/_sass/uikit/components/grid-masonry.scss +++ /dev/null @@ -1,69 +0,0 @@ -// Name: Grid -// Description: Component to create two dimensional grids -// -// Component: `uk-grid2` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$grid-column-xsmall: 100px !default; -$grid-column-small: 200px !default; -$grid-column-medium: 300px !default; -$grid-column-large: 400px !default; -$grid-column-xlarge: 500px !default; -$grid-column-xxlarge: 600px !default; - -$grid-gap-small: $global-small-gutter !default; -$grid-gap-medium: $global-gutter !default; -$grid-gap-large: $global-large-gutter !default; - - -/* ======================================================================== - Component: Grid - ========================================================================== */ - -.uk-grid-masonry { display: grid; } -.uk-grid-inline { display: inline-grid; } - - -/* Columns Width - ========================================================================== */ - -.uk-grid-column-xsmall { grid-template-columns: repeat(auto-fill, minmax($grid-column-xsmall,1fr)); } -.uk-grid-column-small { grid-template-columns: repeat(auto-fill, minmax($grid-column-small,1fr)); } -.uk-grid-column-medium { grid-template-columns: repeat(auto-fill, minmax($grid-column-medium,1fr)); } -.uk-grid-column-large { grid-template-columns: repeat(auto-fill, minmax($grid-column-large,1fr)); } -.uk-grid-column-xlarge { grid-template-columns: repeat(auto-fill, minmax($grid-column-xlarge,1fr)); } -.uk-grid-column-xxlarge { grid-template-columns: repeat(auto-fill, minmax($grid-column-xxlarge,1fr)); } - - -/* Gap - ========================================================================== */ - -.uk-grid-gap-none { grid-gap: 0; } -.uk-grid-gap-small { grid-gap: $grid-gap-small; } -.uk-grid-gap-medium { grid-gap: $grid-gap-medium; } -.uk-grid-gap-large { grid-gap: $grid-gap-large; } - - -/* Auto Placement - ========================================================================== */ - -// Default -.uk-grid-auto-flow-row { grid-auto-flow: row; } -.uk-grid-auto-flow-column { grid-auto-flow: column; } -.uk-grid-auto-flow-dense { grid-auto-flow: dense; } - - -/* Item Span - ========================================================================== */ - -// TODO Fix implicit tracks if span is too large -.uk-grid-item-span-2 { grid-column-start: span 2; } -.uk-grid-item-span-3 { grid-column-start: span 3; } -.uk-grid-item-span-4 { grid-column-start: span 4; } -.uk-grid-item-span-5 { grid-column-start: span 5; } - diff --git a/docs/_sass/uikit/components/grid.scss b/docs/_sass/uikit/components/grid.scss deleted file mode 100644 index 3f92c877e5..0000000000 --- a/docs/_sass/uikit/components/grid.scss +++ /dev/null @@ -1,407 +0,0 @@ -// Name: Grid -// Description: Component to create responsive, fluid and nestable grids -// -// Component: `uk-grid` -// -// Modifiers: `uk-grid-small` -// `uk-grid-medium` -// `uk-grid-large` -// `uk-grid-collapse` -// `uk-grid-divider` -// `uk-grid-match` -// `uk-grid-stack` -// `uk-grid-margin` -// `uk-grid-margin-small` -// `uk-grid-margin-medium` -// `uk-grid-margin-large` -// `uk-grid-margin-collapse` -// -// Sub-modifier: `uk-grid-item-match` -// -// States: `uk-first-column` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$grid-gutter-horizontal: $global-gutter !default; -$grid-gutter-vertical: $grid-gutter-horizontal !default; -$grid-gutter-horizontal-l: $global-medium-gutter !default; -$grid-gutter-vertical-l: $grid-gutter-horizontal-l !default; - -$grid-small-gutter-horizontal: $global-small-gutter !default; -$grid-small-gutter-vertical: $grid-small-gutter-horizontal !default; - -$grid-medium-gutter-horizontal: $global-gutter !default; -$grid-medium-gutter-vertical: $grid-medium-gutter-horizontal !default; - -$grid-large-gutter-horizontal: $global-medium-gutter !default; -$grid-large-gutter-vertical: $grid-large-gutter-horizontal !default; -$grid-large-gutter-horizontal-l: $global-large-gutter !default; -$grid-large-gutter-vertical-l: $grid-large-gutter-horizontal-l !default; - -$grid-divider-border-width: $global-border-width !default; -$grid-divider-border: $global-border !default; - - -/* ======================================================================== - Component: Grid - ========================================================================== */ - -/* - * 1. Allow cells to wrap into the next line - * 2. Reset list - */ - -.uk-grid { - display: flex; - /* 1 */ - flex-wrap: wrap; - /* 2 */ - margin: 0; - padding: 0; - list-style: none; -} - -/* - * Grid cell - * Note: Space is allocated solely based on content dimensions, but shrinks: 0 1 auto - * Reset margin for e.g. paragraphs - */ - -.uk-grid > * { margin: 0; } - -/* - * Remove margin from the last-child - */ - -.uk-grid > * > :last-child { margin-bottom: 0; } - - -/* Gutter - ========================================================================== */ - -/* - * Default - */ - -/* Horizontal */ -.uk-grid { margin-left: (-$grid-gutter-horizontal); } -.uk-grid > * { padding-left: $grid-gutter-horizontal; } - -/* Vertical */ -.uk-grid + .uk-grid, -.uk-grid > .uk-grid-margin, -* + .uk-grid-margin { margin-top: $grid-gutter-vertical; } - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - /* Horizontal */ - .uk-grid { margin-left: (-$grid-gutter-horizontal-l); } - .uk-grid > * { padding-left: $grid-gutter-horizontal-l; } - - /* Vertical */ - .uk-grid + .uk-grid, - .uk-grid > .uk-grid-margin, - * + .uk-grid-margin { margin-top: $grid-gutter-vertical-l; } - -} - -/* - * Small - */ - -/* Horizontal */ -.uk-grid-small, -.uk-grid-column-small { margin-left: (-$grid-small-gutter-horizontal); } -.uk-grid-small > *, -.uk-grid-column-small > * { padding-left: $grid-small-gutter-horizontal; } - -/* Vertical */ -.uk-grid + .uk-grid-small, -.uk-grid + .uk-grid-row-small, -.uk-grid-small > .uk-grid-margin, -.uk-grid-row-small > .uk-grid-margin, -* + .uk-grid-margin-small { margin-top: $grid-small-gutter-vertical; } - -/* - * Medium - */ - -/* Horizontal */ -.uk-grid-medium, -.uk-grid-column-medium { margin-left: (-$grid-medium-gutter-horizontal); } -.uk-grid-medium > *, -.uk-grid-column-medium > * { padding-left: $grid-medium-gutter-horizontal; } - -/* Vertical */ -.uk-grid + .uk-grid-medium, -.uk-grid + .uk-grid-row-medium, -.uk-grid-medium > .uk-grid-margin, -.uk-grid-row-medium > .uk-grid-margin, -* + .uk-grid-margin-medium { margin-top: $grid-medium-gutter-vertical; } - -/* - * Large - */ - -/* Horizontal */ -.uk-grid-large, -.uk-grid-column-large { margin-left: (-$grid-large-gutter-horizontal); } -.uk-grid-large > *, -.uk-grid-column-large > * { padding-left: $grid-large-gutter-horizontal; } - -/* Vertical */ -.uk-grid + .uk-grid-large, -.uk-grid + .uk-grid-row-large, -.uk-grid-large > .uk-grid-margin, -.uk-grid-row-large > .uk-grid-margin, -* + .uk-grid-margin-large { margin-top: $grid-large-gutter-vertical; } - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - /* Horizontal */ - .uk-grid-large, - .uk-grid-column-large { margin-left: (-$grid-large-gutter-horizontal-l); } - .uk-grid-large > *, - .uk-grid-column-large > * { padding-left: $grid-large-gutter-horizontal-l; } - - /* Vertical */ - .uk-grid + .uk-grid-large, - .uk-grid + .uk-grid-row-large, - .uk-grid-large > .uk-grid-margin, - .uk-grid-row-large > .uk-grid-margin, - * + .uk-grid-margin-large { margin-top: $grid-large-gutter-vertical-l; } - -} - -/* - * Collapse - */ - -/* Horizontal */ -.uk-grid-collapse, -.uk-grid-column-collapse { margin-left: 0; } -.uk-grid-collapse > *, -.uk-grid-column-collapse > * { padding-left: 0; } - -/* Vertical */ -.uk-grid + .uk-grid-collapse, -.uk-grid + .uk-grid-row-collapse, -.uk-grid-collapse > .uk-grid-margin, -.uk-grid-row-collapse > .uk-grid-margin { margin-top: 0; } - - -/* Divider - ========================================================================== */ - -.uk-grid-divider > * { position: relative; } - -.uk-grid-divider > :not(.uk-first-column)::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - border-left: $grid-divider-border-width solid $grid-divider-border; - @if(mixin-exists(hook-grid-divider-horizontal)) {@include hook-grid-divider-horizontal();} -} - -/* Vertical */ -.uk-grid-divider.uk-grid-stack > .uk-grid-margin::before { - content: ""; - position: absolute; - left: 0; - right: 0; - border-top: $grid-divider-border-width solid $grid-divider-border; - @if(mixin-exists(hook-grid-divider-vertical)) {@include hook-grid-divider-vertical();} -} - -/* - * Default - */ - -/* Horizontal */ -.uk-grid-divider { margin-left: -($grid-gutter-horizontal * 2); } -.uk-grid-divider > * { padding-left: ($grid-gutter-horizontal * 2); } - -.uk-grid-divider > :not(.uk-first-column)::before { left: $grid-gutter-horizontal; } - -/* Vertical */ -.uk-grid-divider.uk-grid-stack > .uk-grid-margin { margin-top: ($grid-gutter-vertical * 2); } - -.uk-grid-divider.uk-grid-stack > .uk-grid-margin::before { - top: (-$grid-gutter-vertical); - left: ($grid-gutter-horizontal * 2); -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - /* Horizontal */ - .uk-grid-divider { margin-left: -($grid-gutter-horizontal-l * 2); } - .uk-grid-divider > * { padding-left: ($grid-gutter-horizontal-l * 2); } - - .uk-grid-divider > :not(.uk-first-column)::before { left: $grid-gutter-horizontal-l; } - - /* Vertical */ - .uk-grid-divider.uk-grid-stack > .uk-grid-margin { margin-top: ($grid-gutter-vertical-l * 2); } - - .uk-grid-divider.uk-grid-stack > .uk-grid-margin::before { - top: (-$grid-gutter-vertical-l); - left: ($grid-gutter-horizontal-l * 2); - } - -} - -/* - * Small - */ - -/* Horizontal */ -.uk-grid-divider.uk-grid-small, -.uk-grid-divider.uk-grid-column-small { margin-left: -($grid-small-gutter-horizontal * 2); } -.uk-grid-divider.uk-grid-small > *, -.uk-grid-divider.uk-grid-column-small > * { padding-left: ($grid-small-gutter-horizontal * 2); } - -.uk-grid-divider.uk-grid-small > :not(.uk-first-column)::before, -.uk-grid-divider.uk-grid-column-small > :not(.uk-first-column)::before { left: $grid-small-gutter-horizontal; } - -/* Vertical */ -.uk-grid-divider.uk-grid-small.uk-grid-stack > .uk-grid-margin, -.uk-grid-divider.uk-grid-row-small.uk-grid-stack > .uk-grid-margin { margin-top: ($grid-small-gutter-vertical * 2); } - -.uk-grid-divider.uk-grid-small.uk-grid-stack > .uk-grid-margin::before { - top: (-$grid-small-gutter-vertical); - left: ($grid-small-gutter-horizontal * 2); -} - -.uk-grid-divider.uk-grid-row-small.uk-grid-stack > .uk-grid-margin::before { top: (-$grid-small-gutter-vertical); } -.uk-grid-divider.uk-grid-column-small.uk-grid-stack > .uk-grid-margin::before { left: ($grid-small-gutter-horizontal * 2); } - -/* - * Medium - */ - -/* Horizontal */ -.uk-grid-divider.uk-grid-medium, -.uk-grid-divider.uk-grid-column-medium { margin-left: -($grid-medium-gutter-horizontal * 2); } -.uk-grid-divider.uk-grid-medium > *, -.uk-grid-divider.uk-grid-column-medium > * { padding-left: ($grid-medium-gutter-horizontal * 2); } - -.uk-grid-divider.uk-grid-medium > :not(.uk-first-column)::before, -.uk-grid-divider.uk-grid-column-medium > :not(.uk-first-column)::before { left: $grid-medium-gutter-horizontal; } - -/* Vertical */ -.uk-grid-divider.uk-grid-medium.uk-grid-stack > .uk-grid-margin, -.uk-grid-divider.uk-grid-row-medium.uk-grid-stack > .uk-grid-margin { margin-top: ($grid-medium-gutter-vertical * 2); } - -.uk-grid-divider.uk-grid-medium.uk-grid-stack > .uk-grid-margin::before { - top: (-$grid-medium-gutter-vertical); - left: ($grid-medium-gutter-horizontal * 2); -} - -.uk-grid-divider.uk-grid-row-medium.uk-grid-stack > .uk-grid-margin::before { top: (-$grid-medium-gutter-vertical); } -.uk-grid-divider.uk-grid-column-medium.uk-grid-stack > .uk-grid-margin::before { left: ($grid-medium-gutter-horizontal * 2); } - -/* - * Large - */ - -/* Horizontal */ -.uk-grid-divider.uk-grid-large, -.uk-grid-divider.uk-grid-column-large { margin-left: -($grid-large-gutter-horizontal * 2); } -.uk-grid-divider.uk-grid-large > *, -.uk-grid-divider.uk-grid-column-large > * { padding-left: ($grid-large-gutter-horizontal * 2); } - -.uk-grid-divider.uk-grid-large > :not(.uk-first-column)::before, -.uk-grid-divider.uk-grid-column-large > :not(.uk-first-column)::before { left: $grid-large-gutter-horizontal; } - -/* Vertical */ -.uk-grid-divider.uk-grid-large.uk-grid-stack > .uk-grid-margin, -.uk-grid-divider.uk-grid-row-large.uk-grid-stack > .uk-grid-margin { margin-top: ($grid-large-gutter-vertical * 2); } - -.uk-grid-divider.uk-grid-large.uk-grid-stack > .uk-grid-margin::before { - top: (-$grid-large-gutter-vertical); - left: ($grid-large-gutter-horizontal * 2); -} - -.uk-grid-divider.uk-grid-row-large.uk-grid-stack > .uk-grid-margin::before { top: (-$grid-large-gutter-vertical); } -.uk-grid-divider.uk-grid-column-large.uk-grid-stack > .uk-grid-margin::before { left: ($grid-large-gutter-horizontal * 2); } - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - /* Horizontal */ - .uk-grid-divider.uk-grid-large, - .uk-grid-divider.uk-grid-column-large { margin-left: -($grid-large-gutter-horizontal-l * 2); } - .uk-grid-divider.uk-grid-large > *, - .uk-grid-divider.uk-grid-column-large > * { padding-left: ($grid-large-gutter-horizontal-l * 2); } - - .uk-grid-divider.uk-grid-large > :not(.uk-first-column)::before, - .uk-grid-divider.uk-grid-column-large > :not(.uk-first-column)::before { left: $grid-large-gutter-horizontal-l; } - - /* Vertical */ - .uk-grid-divider.uk-grid-large.uk-grid-stack > .uk-grid-margin, - .uk-grid-divider.uk-grid-row-large.uk-grid-stack > .uk-grid-margin { margin-top: ($grid-large-gutter-vertical-l * 2); } - - .uk-grid-divider.uk-grid-large.uk-grid-stack > .uk-grid-margin::before { - top: (-$grid-large-gutter-vertical-l); - left: ($grid-large-gutter-horizontal-l * 2); - } - - .uk-grid-divider.uk-grid-row-large.uk-grid-stack > .uk-grid-margin::before { top: (-$grid-large-gutter-vertical-l); } - .uk-grid-divider.uk-grid-column-large.uk-grid-stack > .uk-grid-margin::before { left: ($grid-large-gutter-horizontal-l * 2); } - -} - - -/* Match child of a grid cell - ========================================================================== */ - -/* - * Behave like a block element - * 1. Wrap into the next line - * 2. Take the full width, at least 100%. Only if no class from the Width component is set. - * 3. Expand width even if larger than 100%, e.g. because of negative margin (Needed for nested grids) - */ - -.uk-grid-match > *, -.uk-grid-item-match { - display: flex; - /* 1 */ - flex-wrap: wrap; -} - -.uk-grid-match > * > :not([class*='uk-width']), -.uk-grid-item-match > :not([class*='uk-width']) { - /* 2 */ - box-sizing: border-box; - width: 100%; - /* 3 */ - flex: auto; -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-grid-misc)) {@include hook-grid-misc();} - -// @mixin hook-grid-divider-horizontal(){} -// @mixin hook-grid-divider-vertical(){} -// @mixin hook-grid-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-grid-divider-border: $inverse-global-border !default; - - - -// @mixin hook-inverse-grid-divider-horizontal(){} -// @mixin hook-inverse-grid-divider-vertical(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/heading.scss b/docs/_sass/uikit/components/heading.scss deleted file mode 100644 index 05518547d2..0000000000 --- a/docs/_sass/uikit/components/heading.scss +++ /dev/null @@ -1,321 +0,0 @@ -// Name: Heading -// Description: Styles for headings -// -// Component: `uk-heading-primary` -// `uk-heading-hero` -// `uk-heading-divider` -// `uk-heading-bullet` -// `uk-heading-line` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$heading-small-font-size: $heading-small-font-size-m * 0.8 !default; // 38px 0.73 -$heading-medium-font-size: $heading-medium-font-size-m * 0.825 !default; // 40px 0.714 -$heading-large-font-size: $heading-large-font-size-m * 0.85 !default; // 50px 0.78 -$heading-xlarge-font-size: $heading-large-font-size-m !default; // 4rem / 64px -$heading-2xlarge-font-size: $heading-xlarge-font-size-m !default; // 6rem / 96px - -$heading-small-font-size-m: $heading-medium-font-size-l * 0.8125 !default; // 3.25rem / 52px -$heading-medium-font-size-m: $heading-medium-font-size-l * 0.875 !default; // 3.5rem / 56px -$heading-large-font-size-m: $heading-medium-font-size-l !default; // 4rem / 64px -$heading-xlarge-font-size-m: $heading-large-font-size-l !default; // 6rem / 96px -$heading-2xlarge-font-size-m: $heading-xlarge-font-size-l !default; // 8rem / 128px - -$heading-medium-font-size-l: 4rem !default; // 64px -$heading-large-font-size-l: 6rem !default; // 96px -$heading-xlarge-font-size-l: 8rem !default; // 128px -$heading-2xlarge-font-size-l: 11rem !default; // 176px - -$heading-small-line-height: 1.2 !default; -$heading-medium-line-height: 1.1 !default; -$heading-large-line-height: 1.1 !default; -$heading-xlarge-line-height: 1 !default; -$heading-2xlarge-line-height: 1 !default; - -$heading-divider-padding-bottom: unquote('calc(5px + 0.1em)') !default; -$heading-divider-border-width: unquote('calc(0.2px + 0.05em)') !default; -$heading-divider-border: $global-border !default; - -$heading-bullet-top: unquote('calc(-0.1 * 1em)') !default; -$heading-bullet-height: unquote('calc(4px + 0.7em)') !default; -$heading-bullet-margin-right: unquote('calc(5px + 0.2em)') !default; -$heading-bullet-border-width: unquote('calc(5px + 0.1em)') !default; -$heading-bullet-border: $global-border !default; - -$heading-line-top: 50% !default; -$heading-line-height: $heading-line-border-width !default; -$heading-line-width: 2000px !default; -$heading-line-border-width: unquote('calc(0.2px + 0.05em)') !default; -$heading-line-border: $global-border !default; -$heading-line-margin-horizontal: unquote('calc(5px + 0.3em)') !default; - - -/* ======================================================================== - Component: Heading - ========================================================================== */ - -.uk-heading-small { - font-size: $heading-small-font-size; - line-height: $heading-small-line-height; - @if(mixin-exists(hook-heading-small)) {@include hook-heading-small();} -} - -.uk-heading-medium { - font-size: $heading-medium-font-size; - line-height: $heading-medium-line-height; - @if(mixin-exists(hook-heading-medium)) {@include hook-heading-medium();} -} - -.uk-heading-large { - font-size: $heading-large-font-size; - line-height: $heading-large-line-height; - @if(mixin-exists(hook-heading-large)) {@include hook-heading-large();} -} - -.uk-heading-xlarge { - font-size: $heading-xlarge-font-size; - line-height: $heading-xlarge-line-height; - @if(mixin-exists(hook-heading-xlarge)) {@include hook-heading-xlarge();} -} - -.uk-heading-2xlarge { - font-size: $heading-2xlarge-font-size; - line-height: $heading-2xlarge-line-height; - @if(mixin-exists(hook-heading-2xlarge)) {@include hook-heading-2xlarge();} -} - -/* Tablet Landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-heading-small { font-size: $heading-small-font-size-m; } - .uk-heading-medium { font-size: $heading-medium-font-size-m; } - .uk-heading-large { font-size: $heading-large-font-size-m; } - .uk-heading-xlarge { font-size: $heading-xlarge-font-size-m; } - .uk-heading-2xlarge { font-size: $heading-2xlarge-font-size-m; } - -} - -/* Laptop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-heading-medium { font-size: $heading-medium-font-size-l; } - .uk-heading-large { font-size: $heading-large-font-size-l; } - .uk-heading-xlarge { font-size: $heading-xlarge-font-size-l; } - .uk-heading-2xlarge { font-size: $heading-2xlarge-font-size-l; } - -} - - -/* Primary - Deprecated: Use `uk-heading-medium` instead - ========================================================================== */ - -$heading-primary-font-size-l: 3.75rem !default; // 60px -$heading-primary-line-height-l: 1.1 !default; - -$heading-primary-font-size-m: $heading-primary-font-size-l * 0.9 !default; // 54px - -$heading-primary-font-size: $heading-primary-font-size-l * 0.8 !default; // 48px -$heading-primary-line-height: 1.2 !default; - -@if ($deprecated == true) { -.uk-heading-primary { - font-size: $heading-primary-font-size; - line-height: $heading-primary-line-height; - @if(mixin-exists(hook-heading-primary)) {@include hook-heading-primary();} -} -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - @if ($deprecated == true) { -.uk-heading-primary { font-size: $heading-primary-font-size-m; } -} - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - @if ($deprecated == true) { -.uk-heading-primary { - font-size: $heading-primary-font-size-l; - line-height: $heading-primary-line-height-l; - } -} - -} - - -/* Hero - Deprecated: Use `uk-heading-xlarge` instead - ========================================================================== */ - -$heading-hero-font-size-l: 8rem !default; // 128px -$heading-hero-line-height-l: 1 !default; - -$heading-hero-font-size-m: $heading-hero-font-size-l * 0.75 !default; // 96px -$heading-hero-line-height-m: 1 !default; - -$heading-hero-font-size: $heading-hero-font-size-l * 0.5 !default; // 64px -$heading-hero-line-height: 1.1 !default; - -@if ($deprecated == true) { -.uk-heading-hero { - font-size: $heading-hero-font-size; - line-height: $heading-hero-line-height; - @if(mixin-exists(hook-heading-hero)) {@include hook-heading-hero();} -} -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - @if ($deprecated == true) { -.uk-heading-hero { - font-size: $heading-hero-font-size-m; - line-height: $heading-hero-line-height-m; - } -} - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - @if ($deprecated == true) { -.uk-heading-hero { - font-size: $heading-hero-font-size-l; - line-height: $heading-hero-line-height-l; - } -} - -} - - -/* Divider - ========================================================================== */ - -.uk-heading-divider { - padding-bottom: $heading-divider-padding-bottom; - border-bottom: $heading-divider-border-width solid $heading-divider-border; - @if(mixin-exists(hook-heading-divider)) {@include hook-heading-divider();} -} - - -/* Bullet - ========================================================================== */ - -.uk-heading-bullet { position: relative; } - -/* - * 1. Using `inline-block` to make it work with text alignment - * 2. Center vertically - * 3. Style - */ - -.uk-heading-bullet::before { - content: ""; - /* 1 */ - display: inline-block; - /* 2 */ - position: relative; - top: $heading-bullet-top; - vertical-align: middle; - /* 3 */ - height: $heading-bullet-height; - margin-right: $heading-bullet-margin-right; - border-left: $heading-bullet-border-width solid $heading-bullet-border; - @if(mixin-exists(hook-heading-bullet)) {@include hook-heading-bullet();} -} - - -/* Line - ========================================================================== */ - -/* - * Clip the child element - */ - -.uk-heading-line { overflow: hidden; } - -/* - * Extra markup is needed to make it work with text align - */ - -.uk-heading-line > * { - display: inline-block; - position: relative; -} - -/* - * 1. Center vertically - * 2. Make the element as large as possible. It's clipped by the container. - * 3. Style - */ - -.uk-heading-line > ::before, -.uk-heading-line > ::after { - content: ""; - /* 1 */ - position: absolute; - top: unquote('calc(#{$heading-line-top} - (#{$heading-line-height} / 2))'); - /* 2 */ - width: $heading-line-width; - /* 3 */ - border-bottom: $heading-line-border-width solid $heading-line-border; - @if(mixin-exists(hook-heading-line)) {@include hook-heading-line();} -} - -.uk-heading-line > ::before { - right: 100%; - margin-right: $heading-line-margin-horizontal; -} -.uk-heading-line > ::after { - left: 100%; - margin-left: $heading-line-margin-horizontal; -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-heading-misc)) {@include hook-heading-misc();} - -// @mixin hook-heading-small(){} -// @mixin hook-heading-medium(){} -// @mixin hook-heading-large(){} -// @mixin hook-heading-xlarge(){} -// @mixin hook-heading-2xlarge(){} -// @mixin hook-heading-primary(){} -// @mixin hook-heading-hero(){} -// @mixin hook-heading-divider(){} -// @mixin hook-heading-bullet(){} -// @mixin hook-heading-line(){} -// @mixin hook-heading-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-heading-divider-border: $inverse-global-border !default; -$inverse-heading-bullet-border: $inverse-global-border !default; -$inverse-heading-line-border: $inverse-global-border !default; - - - -// @mixin hook-inverse-heading-small(){} -// @mixin hook-inverse-heading-medium(){} -// @mixin hook-inverse-heading-large(){} -// @mixin hook-inverse-heading-xlarge(){} -// @mixin hook-inverse-heading-2xlarge(){} -// @mixin hook-inverse-heading-primary(){} -// @mixin hook-inverse-heading-hero(){} -// @mixin hook-inverse-heading-divider(){} -// @mixin hook-inverse-heading-bullet(){} -// @mixin hook-inverse-heading-line(){} diff --git a/docs/_sass/uikit/components/height.scss b/docs/_sass/uikit/components/height.scss deleted file mode 100644 index 3bcc150436..0000000000 --- a/docs/_sass/uikit/components/height.scss +++ /dev/null @@ -1,54 +0,0 @@ -// Name: Height -// Description: Utilities for heights -// -// Component: `uk-height-*` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$height-small-height: 150px !default; -$height-medium-height: 300px !default; -$height-large-height: 450px !default; - - -/* ======================================================================== - Component: Height - ========================================================================== */ - -[class*='uk-height'] { box-sizing: border-box; } - -/* - * Only works if parent element has a height set - */ - -.uk-height-1-1 { height: 100%; } - -/* - * Useful to create image teasers - */ - -.uk-height-viewport { min-height: 100vh; } - -/* - * Pixel - * Useful for `overflow: auto` - */ - -.uk-height-small { height: $height-small-height; } -.uk-height-medium { height: $height-medium-height; } -.uk-height-large { height: $height-large-height; } - -.uk-height-max-small { max-height: $height-small-height; } -.uk-height-max-medium { max-height: $height-medium-height; } -.uk-height-max-large { max-height: $height-large-height; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-height-misc)) {@include hook-height-misc();} - -// @mixin hook-height-misc(){} diff --git a/docs/_sass/uikit/components/icon.scss b/docs/_sass/uikit/components/icon.scss deleted file mode 100644 index d801424b5b..0000000000 --- a/docs/_sass/uikit/components/icon.scss +++ /dev/null @@ -1,220 +0,0 @@ -// Name: Icon -// Description: Component to create icons -// -// Component: `uk-icon` -// -// Modifiers: `uk-icon-image` -// `uk-icon-link` -// `uk-icon-button` -// -// States: `uk-preserve` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$icon-image-size: 20px !default; - -$icon-link-color: $global-muted-color !default; -$icon-link-hover-color: $global-color !default; -$icon-link-active-color: darken($global-color, 5%) !default; - -$icon-button-size: 36px !default; -$icon-button-border-radius: 500px !default; -$icon-button-background: $global-muted-background !default; -$icon-button-color: $global-muted-color !default; - -$icon-button-hover-background: darken($icon-button-background, 5%) !default; -$icon-button-hover-color: $global-color !default; - -$icon-button-active-background: darken($icon-button-background, 10%) !default; -$icon-button-active-color: $global-color !default; - - -/* ======================================================================== - Component: Icon - ========================================================================== */ - -/* - * Note: 1. - 7. is required for `button` elements. Needed for Close and Form Icon component. - * 1. Remove margins in Chrome, Safari and Opera. - * 2. Remove borders for `button`. - * 3. Remove border-radius in Chrome. - * 4. Address `overflow` set to `hidden` in IE. - * 5. Correct `font` properties and `color` not being inherited for `button`. - * 6. Remove the inheritance of text transform in Edge, Firefox, and IE. - * 7. Remove default `button` padding and background color - * 8. Style - * 9. Fill all SVG elements with the current text color if no `fill` attribute is set - * 10. Let the container fit the height of the icon - */ - -.uk-icon { - /* 1 */ - margin: 0; - /* 2 */ - border: none; - /* 3 */ - border-radius: 0; - /* 4 */ - overflow: visible; - /* 5 */ - font: inherit; - color: inherit; - /* 6 */ - text-transform: none; - /* 7. */ - padding: 0; - background-color: transparent; - /* 8 */ - display: inline-block; - /* 9 */ - fill: currentcolor; - /* 10 */ - line-height: 0; -} - -/* Required for `button`. */ -button.uk-icon:not(:disabled) { cursor: pointer; } - -/* - * Remove the inner border and padding in Firefox. - */ - -.uk-icon::-moz-focus-inner { - border: 0; - padding: 0; -} - -/* - * Set the fill and stroke color of all SVG elements to the current text color - */ - -.uk-icon:not(.uk-preserve) [fill*='#']:not(.uk-preserve) { fill: currentcolor; } -.uk-icon:not(.uk-preserve) [stroke*='#']:not(.uk-preserve) { stroke: currentcolor; } - -/* - * Fix Firefox blurry SVG rendering: https://bugzilla.mozilla.org/show_bug.cgi?id=1046835 - */ - -.uk-icon > * { transform: translate(0,0); } - - -/* Image modifier - ========================================================================== */ - -/* - * Display images in icon dimensions - */ - -.uk-icon-image { - width: $icon-image-size; - height: $icon-image-size; - background-position: 50% 50%; - background-repeat: no-repeat; - background-size: contain; - vertical-align: middle; -} - - -/* Style modifiers - ========================================================================== */ - -/* - * Link - */ - -.uk-icon-link { - color: $icon-link-color; - @if(mixin-exists(hook-icon-link)) {@include hook-icon-link();} -} - -.uk-icon-link:hover, -.uk-icon-link:focus { - color: $icon-link-hover-color; - outline: none; - @if(mixin-exists(hook-icon-link-hover)) {@include hook-icon-link-hover();} -} - -/* OnClick + Active */ -.uk-icon-link:active, -.uk-active > .uk-icon-link { - color: $icon-link-active-color; - @if(mixin-exists(hook-icon-link-active)) {@include hook-icon-link-active();} -} - -/* - * Button - * 1. Center icon vertically and horizontally - */ - -.uk-icon-button { - box-sizing: border-box; - width: $icon-button-size; - height: $icon-button-size; - border-radius: $icon-button-border-radius; - background: $icon-button-background; - color: $icon-button-color; - vertical-align: middle; - /* 1 */ - display: inline-flex; - justify-content: center; - align-items: center; - @if(mixin-exists(hook-icon-button)) {@include hook-icon-button();} -} - -/* Hover + Focus */ -.uk-icon-button:hover, -.uk-icon-button:focus { - background-color: $icon-button-hover-background; - color: $icon-button-hover-color; - outline: none; - @if(mixin-exists(hook-icon-button-hover)) {@include hook-icon-button-hover();} -} - -/* OnClick + Active */ -.uk-icon-button:active, -.uk-active > .uk-icon-button { - background-color: $icon-button-active-background; - color: $icon-button-active-color; - @if(mixin-exists(hook-icon-button-active)) {@include hook-icon-button-active();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-icon-misc)) {@include hook-icon-misc();} - -// @mixin hook-icon-link(){} -// @mixin hook-icon-link-hover(){} -// @mixin hook-icon-link-active(){} -// @mixin hook-icon-button(){} -// @mixin hook-icon-button-hover(){} -// @mixin hook-icon-button-active(){} -// @mixin hook-icon-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-icon-link-color: $inverse-global-muted-color !default; -$inverse-icon-link-hover-color: $inverse-global-color !default; -$inverse-icon-link-active-color: $inverse-global-color !default; -$inverse-icon-button-background: $inverse-global-muted-background !default; -$inverse-icon-button-color: $inverse-global-muted-color !default; -$inverse-icon-button-hover-background: fadein($inverse-icon-button-background, 5%) !default; -$inverse-icon-button-hover-color: $inverse-global-color !default; -$inverse-icon-button-active-background: fadein($inverse-icon-button-background, 10%) !default; -$inverse-icon-button-active-color: $inverse-global-color !default; - - - -// @mixin hook-inverse-icon-link(){} -// @mixin hook-inverse-icon-link-hover(){} -// @mixin hook-inverse-icon-link-active(){} -// @mixin hook-inverse-icon-button(){} -// @mixin hook-inverse-icon-button-hover(){} -// @mixin hook-inverse-icon-button-active(){} diff --git a/docs/_sass/uikit/components/iconnav.scss b/docs/_sass/uikit/components/iconnav.scss deleted file mode 100644 index e0d2ae1a14..0000000000 --- a/docs/_sass/uikit/components/iconnav.scss +++ /dev/null @@ -1,148 +0,0 @@ -// Name: Iconnav -// Description: Component to create icon navigations -// -// Component: `uk-iconnav` -// -// Modifier: `uk-iconnav-vertical` -// -// States: `uk-active` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$iconnav-margin-horizontal: $global-small-margin !default; -$iconnav-margin-vertical: $iconnav-margin-horizontal !default; - -$iconnav-item-color: $global-muted-color !default; - -$iconnav-item-hover-color: $global-color !default; - -$iconnav-item-active-color: $global-color !default; - - -/* ======================================================================== - Component: Iconnav - ========================================================================== */ - -/* - * 1. Allow items to wrap into the next line - * 2. Reset list - * 3. Gutter - */ - -.uk-iconnav { - display: flex; - /* 1 */ - flex-wrap: wrap; - /* 2 */ - margin: 0; - padding: 0; - list-style: none; - /* 3 */ - margin-left: (-$iconnav-margin-horizontal); - @if(mixin-exists(hook-iconnav)) {@include hook-iconnav();} -} - -/* - * Space is allocated based on content dimensions, but shrinks: 0 1 auto - * 1. Gutter - */ - -.uk-iconnav > * { - /* 1 */ - padding-left: $iconnav-margin-horizontal; -} - - -/* Items - ========================================================================== */ - -/* - * Items must target `a` elements to exclude other elements (e.g. dropdowns) - * 1. Center content vertically if there is still some text - * 2. Imitate white space gap when using flexbox - * 3. Force text not to affect item height - * 4. Style - * 5. Required for `a` if there is still some text - */ - -.uk-iconnav > * > a { - /* 1 */ - display: flex; - align-items: center; - /* 2 */ - column-gap: 0.25em; - /* 3 */ - line-height: 0; - /* 4 */ - color: $iconnav-item-color; - /* 5 */ - text-decoration: none; - @if(mixin-exists(hook-iconnav-item)) {@include hook-iconnav-item();} -} - -/* Hover + Focus */ -.uk-iconnav > * > a:hover, -.uk-iconnav > * > a:focus { - color: $iconnav-item-hover-color; - outline: none; - @if(mixin-exists(hook-iconnav-item-hover)) {@include hook-iconnav-item-hover();} -} - -/* Active */ -.uk-iconnav > .uk-active > a { - color: $iconnav-item-active-color; - @if(mixin-exists(hook-iconnav-item-active)) {@include hook-iconnav-item-active();} -} - - -/* Modifier: 'uk-iconnav-vertical' - ========================================================================== */ - -/* - * 1. Change direction - * 2. Gutter - */ - -.uk-iconnav-vertical { - /* 1 */ - flex-direction: column; - /* 2 */ - margin-left: 0; - margin-top: (-$iconnav-margin-vertical); -} - -/* 2 */ -.uk-iconnav-vertical > * { - padding-left: 0; - padding-top: $iconnav-margin-vertical; -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-iconnav-misc)) {@include hook-iconnav-misc();} - -// @mixin hook-iconnav(){} -// @mixin hook-iconnav-item(){} -// @mixin hook-iconnav-item-hover(){} -// @mixin hook-iconnav-item-active(){} -// @mixin hook-iconnav-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-iconnav-item-color: $inverse-global-muted-color !default; -$inverse-iconnav-item-hover-color: $inverse-global-color !default; -$inverse-iconnav-item-active-color: $inverse-global-color !default; - - - -// @mixin hook-inverse-iconnav-item(){} -// @mixin hook-inverse-iconnav-item-hover(){} -// @mixin hook-inverse-iconnav-item-active(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/inverse.scss b/docs/_sass/uikit/components/inverse.scss deleted file mode 100644 index cc5efee3f3..0000000000 --- a/docs/_sass/uikit/components/inverse.scss +++ /dev/null @@ -1,46 +0,0 @@ -// Name: Inverse -// Description: Inverse component style for light or dark backgrounds -// -// Component: `uk-light` -// `uk-dark` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$inverse-global-color-mode: light !default; - -$inverse-global-color: rgba($global-inverse-color, 0.7) !default; -$inverse-global-emphasis-color: $global-inverse-color !default; -$inverse-global-muted-color: rgba($global-inverse-color, 0.5) !default; -$inverse-global-inverse-color: $global-color !default; - -$inverse-global-primary-background: $global-inverse-color !default; -$inverse-global-muted-background: rgba($global-inverse-color, 0.1) !default; - -$inverse-global-border: rgba($global-inverse-color, 0.2) !default; - - -/* ======================================================================== - Component: Inverse - ========================================================================== */ - - - -/* - * Implemented class depends on the general theme color - * `uk-light` is for light colors on dark backgrounds - * `uk-dark` is or dark colors on light backgrounds - */ - -@if ($inverse-global-color-mode == light) { .uk-light { @if (mixin-exists(hook-inverse)) {@include hook-inverse();}}} - -@if ($inverse-global-color-mode == dark) { .uk-dark { @if (mixin-exists(hook-inverse)) {@include hook-inverse();}}} - - -// Hooks -// ======================================================================== - -// @mixin hook-inverse(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/label.scss b/docs/_sass/uikit/components/label.scss deleted file mode 100644 index 6600aedfab..0000000000 --- a/docs/_sass/uikit/components/label.scss +++ /dev/null @@ -1,102 +0,0 @@ -// Name: Label -// Description: Component to indicate important notes -// -// Component: `uk-label` -// -// Modifiers: `uk-label-success` -// `uk-label-warning` -// `uk-label-danger` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$label-padding-vertical: 0 !default; -$label-padding-horizontal: $global-small-margin !default; -$label-background: $global-primary-background !default; -$label-line-height: $global-line-height !default; -$label-font-size: $global-small-font-size !default; -$label-color: $global-inverse-color !default; - -$label-success-background: $global-success-background !default; -$label-success-color: $global-inverse-color !default; -$label-warning-background: $global-warning-background !default; -$label-warning-color: $global-inverse-color !default; -$label-danger-background: $global-danger-background !default; -$label-danger-color: $global-inverse-color !default; - - -/* ======================================================================== - Component: Label - ========================================================================== */ - -.uk-label { - display: inline-block; - padding: $label-padding-vertical $label-padding-horizontal; - background: $label-background; - line-height: $label-line-height; - font-size: $label-font-size; - color: $label-color; - vertical-align: middle; - white-space: nowrap; - @if(mixin-exists(hook-label)) {@include hook-label();} -} - - -/* Color modifiers - ========================================================================== */ - -/* - * Success - */ - -.uk-label-success { - background-color: $label-success-background; - color: $label-success-color; - @if(mixin-exists(hook-label-success)) {@include hook-label-success();} -} - -/* - * Warning - */ - -.uk-label-warning { - background-color: $label-warning-background; - color: $label-warning-color; - @if(mixin-exists(hook-label-warning)) {@include hook-label-warning();} -} - -/* - * Danger - */ - -.uk-label-danger { - background-color: $label-danger-background; - color: $label-danger-color; - @if(mixin-exists(hook-label-danger)) {@include hook-label-danger();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-label-misc)) {@include hook-label-misc();} - -// @mixin hook-label(){} -// @mixin hook-label-success(){} -// @mixin hook-label-warning(){} -// @mixin hook-label-danger(){} -// @mixin hook-label-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-label-background: $inverse-global-primary-background !default; -$inverse-label-color: $inverse-global-inverse-color !default; - - - -// @mixin hook-inverse-label(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/leader.scss b/docs/_sass/uikit/components/leader.scss deleted file mode 100644 index a671f098bf..0000000000 --- a/docs/_sass/uikit/components/leader.scss +++ /dev/null @@ -1,70 +0,0 @@ -// Name: Leader -// Description: Component to create dot leaders -// -// Component: `uk-leader` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$leader-fill-content: unquote('.') !default; -$leader-fill-margin-left: $global-small-gutter !default; - - -/* ======================================================================== - Component: Leader - ========================================================================== */ - -.uk-leader { overflow: hidden; } - -/* - * 1. Place element in text flow - * 2. Never break into a new line - * 3. Get a string back with as many repeating characters to fill the container - * 4. Prevent wrapping. Overflowing characters will be clipped by the container - */ - -.uk-leader-fill::after { - /* 1 */ - display: inline-block; - margin-left: $leader-fill-margin-left; - /* 2 */ - width: 0; - /* 3 */ - content: attr(data-fill); - /* 4 */ - white-space: nowrap; - @if(mixin-exists(hook-leader)) {@include hook-leader();} -} - -/* - * Hide if media does not match - */ - -.uk-leader-fill.uk-leader-hide::after { display: none; } - -/* - * Pass fill character to JS - */ - -.uk-leader-fill-content::before { content: '#{$leader-fill-content}'; } -:root { --uk-leader-fill-content: #{$leader-fill-content}; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-leader-misc)) {@include hook-leader-misc();} - -// @mixin hook-leader(){} -// @mixin hook-leader-misc(){} - - -// Inverse -// ======================================================================== - - - -// @mixin hook-inverse-leader(){} diff --git a/docs/_sass/uikit/components/lightbox.scss b/docs/_sass/uikit/components/lightbox.scss deleted file mode 100644 index 16f58c7882..0000000000 --- a/docs/_sass/uikit/components/lightbox.scss +++ /dev/null @@ -1,245 +0,0 @@ -// Name: Lightbox -// Description: Component to create an lightbox image gallery -// -// Component: `uk-lightbox` -// -// Sub-objects: `uk-lightbox-page` -// `uk-lightbox-items` -// `uk-lightbox-toolbar` -// `uk-lightbox-toolbar-icon` -// `uk-lightbox-button` -// `uk-lightbox-caption` -// `uk-lightbox-iframe` -// -// States: `uk-open` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$lightbox-z-index: $global-z-index + 10 !default; -$lightbox-background: #000 !default; - -$lightbox-item-color: rgba(255,255,255,0.7) !default; -$lightbox-item-max-width: 100vw !default; -$lightbox-item-max-height: 100vh !default; - -$lightbox-toolbar-padding-vertical: 10px !default; -$lightbox-toolbar-padding-horizontal: 10px !default; -$lightbox-toolbar-background: rgba(0,0,0,0.3) !default; -$lightbox-toolbar-color: rgba(255,255,255,0.7) !default; - -$lightbox-toolbar-icon-padding: 5px !default; -$lightbox-toolbar-icon-color: rgba(255,255,255,0.7) !default; - -$lightbox-toolbar-icon-hover-color: #fff !default; - -$lightbox-button-size: 50px !default; -$lightbox-button-background: $lightbox-toolbar-background !default; -$lightbox-button-color: rgba(255,255,255,0.7) !default; - -$lightbox-button-hover-color: #fff !default; - - -/* ======================================================================== - Component: Lightbox - ========================================================================== */ - -/* - * 1. Hide by default - * 2. Set position - * 3. Allow scrolling for the modal dialog - * 4. Horizontal padding - * 5. Mask the background page - * 6. Fade-in transition - * 7. Prevent cancellation of pointer events while dragging - */ - -.uk-lightbox { - /* 1 */ - display: none; - /* 2 */ - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: $lightbox-z-index; - /* 5 */ - background: $lightbox-background; - /* 6 */ - opacity: 0; - transition: opacity 0.15s linear; - /* 7 */ - touch-action: pinch-zoom; - @if(mixin-exists(hook-lightbox)) {@include hook-lightbox();} -} - -/* - * Open - * 1. Center child - * 2. Fade-in - */ - -.uk-lightbox.uk-open { - display: block; - /* 2 */ - opacity: 1; -} - - -/* Page - ========================================================================== */ - -/* - * Prevent scrollbars - */ - -.uk-lightbox-page { overflow: hidden; } - - -/* Item - ========================================================================== */ - -/* - * 1. Center child within the viewport - * 2. Not visible by default - * 3. Color needed for spinner icon - * 4. Optimize animation - * 5. Responsiveness - * Using `vh` for `max-height` to fix image proportions after resize in Safari and Opera - * Using `vh` and `vw` to make responsive image work in IE11 - * 6. Suppress outline on focus - */ - -.uk-lightbox-items > * { - /* 1 */ - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - /* 2 */ - display: none; - justify-content: center; - align-items: center; - /* 3 */ - color: $lightbox-item-color; - /* 4 */ - will-change: transform, opacity; - @if(mixin-exists(hook-lightbox-item)) {@include hook-lightbox-item();} -} - -/* 5 */ -.uk-lightbox-items > * > * { - max-width: $lightbox-item-max-width; - max-height: $lightbox-item-max-height; -} - -/* 6 */ -.uk-lightbox-items > :focus { outline: none; } - -.uk-lightbox-items > * > :not(iframe) { - width: auto; - height: auto; -} - -.uk-lightbox-items > .uk-active { display: flex; } - -/* Toolbar - ========================================================================== */ - -.uk-lightbox-toolbar { - padding: $lightbox-toolbar-padding-vertical $lightbox-toolbar-padding-horizontal; - background: $lightbox-toolbar-background; - color: $lightbox-toolbar-color; - @if(mixin-exists(hook-lightbox-toolbar)) {@include hook-lightbox-toolbar();} -} - -.uk-lightbox-toolbar > * { color: $lightbox-toolbar-color; } - - -/* Toolbar Icon (Close) - ========================================================================== */ - -.uk-lightbox-toolbar-icon { - padding: $lightbox-toolbar-icon-padding; - color: $lightbox-toolbar-icon-color; - @if(mixin-exists(hook-lightbox-toolbar-icon)) {@include hook-lightbox-toolbar-icon();} -} - -/* - * Hover - */ - -.uk-lightbox-toolbar-icon:hover { - color: $lightbox-toolbar-icon-hover-color; - @if(mixin-exists(hook-lightbox-toolbar-icon-hover)) {@include hook-lightbox-toolbar-icon-hover();} -} - - - -/* Button (Slidenav) - ========================================================================== */ - -/* - * 1. Center icon vertically and horizontally - */ - -.uk-lightbox-button { - box-sizing: border-box; - width: $lightbox-button-size; - height: $lightbox-button-size; - background: $lightbox-button-background; - color: $lightbox-button-color; - /* 1 */ - display: inline-flex; - justify-content: center; - align-items: center; - @if(mixin-exists(hook-lightbox-button)) {@include hook-lightbox-button();} -} - -/* Hover + Focus */ -.uk-lightbox-button:hover, -.uk-lightbox-button:focus { - color: $lightbox-button-hover-color; - @if(mixin-exists(hook-lightbox-button-hover)) {@include hook-lightbox-button-hover();} -} - -/* OnClick */ -.uk-lightbox-button:active { - @if(mixin-exists(hook-lightbox-button-active)) {@include hook-lightbox-button-active();} -} - - -/* Caption - ========================================================================== */ - -.uk-lightbox-caption:empty { display: none; } - - -/* Iframe - ========================================================================== */ - -.uk-lightbox-iframe { - width: 80%; - height: 80%; -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-lightbox-misc)) {@include hook-lightbox-misc();} - -// @mixin hook-lightbox(){} -// @mixin hook-lightbox-item(){} -// @mixin hook-lightbox-toolbar(){} -// @mixin hook-lightbox-toolbar-icon(){} -// @mixin hook-lightbox-toolbar-icon-hover(){} -// @mixin hook-lightbox-button(){} -// @mixin hook-lightbox-button-hover(){} -// @mixin hook-lightbox-button-active(){} -// @mixin hook-lightbox-misc(){} diff --git a/docs/_sass/uikit/components/link.scss b/docs/_sass/uikit/components/link.scss deleted file mode 100644 index 2df663525a..0000000000 --- a/docs/_sass/uikit/components/link.scss +++ /dev/null @@ -1,140 +0,0 @@ -// Name: Link -// Description: Styles for links -// -// Component: `uk-link-muted` -// `uk-link-text` -// `uk-link-heading` -// `uk-link-reset` -// -// Sub-objects: `uk-link-toggle` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$link-muted-color: $global-muted-color !default; -$link-muted-hover-color: $global-color !default; - -$link-text-hover-color: $global-muted-color !default; - -$link-heading-hover-color: $global-primary-background !default; -$link-heading-hover-text-decoration: none !default; - - -/* ======================================================================== - Component: Link - ========================================================================== */ - - -/* Muted - ========================================================================== */ - -a.uk-link-muted, -.uk-link-muted a { - color: $link-muted-color; - @if(mixin-exists(hook-link-muted)) {@include hook-link-muted();} -} - -a.uk-link-muted:hover, -.uk-link-muted a:hover, -.uk-link-toggle:hover .uk-link-muted, -.uk-link-toggle:focus .uk-link-muted { - color: $link-muted-hover-color; - @if(mixin-exists(hook-link-muted-hover)) {@include hook-link-muted-hover();} -} - - -/* Text - ========================================================================== */ - -a.uk-link-text, -.uk-link-text a { - color: inherit; - @if(mixin-exists(hook-link-text)) {@include hook-link-text();} -} - -a.uk-link-text:hover, -.uk-link-text a:hover, -.uk-link-toggle:hover .uk-link-text, -.uk-link-toggle:focus .uk-link-text { - color: $link-text-hover-color; - @if(mixin-exists(hook-link-text-hover)) {@include hook-link-text-hover();} -} - - -/* Heading - ========================================================================== */ - -a.uk-link-heading, -.uk-link-heading a { - color: inherit; - @if(mixin-exists(hook-link-heading)) {@include hook-link-heading();} -} - -a.uk-link-heading:hover, -.uk-link-heading a:hover, -.uk-link-toggle:hover .uk-link-heading, -.uk-link-toggle:focus .uk-link-heading { - color: $link-heading-hover-color; - text-decoration: $link-heading-hover-text-decoration; - @if(mixin-exists(hook-link-heading-hover)) {@include hook-link-heading-hover();} -} - - -/* Reset - ========================================================================== */ - -/* - * `!important` needed to override inverse component - */ - -a.uk-link-reset, -.uk-link-reset a { - color: inherit !important; - text-decoration: none !important; - @if(mixin-exists(hook-link-reset)) {@include hook-link-reset();} -} - - -/* Toggle - ========================================================================== */ - -.uk-link-toggle { - color: inherit !important; - text-decoration: none !important; -} - -.uk-link-toggle:focus { outline: none; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-link-misc)) {@include hook-link-misc();} - -// @mixin hook-link-muted(){} -// @mixin hook-link-muted-hover(){} -// @mixin hook-link-text(){} -// @mixin hook-link-text-hover(){} -// @mixin hook-link-heading(){} -// @mixin hook-link-heading-hover(){} -// @mixin hook-link-reset(){} -// @mixin hook-link-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-link-muted-color: $inverse-global-muted-color !default; -$inverse-link-muted-hover-color: $inverse-global-color !default; -$inverse-link-text-hover-color: $inverse-global-muted-color !default; -$inverse-link-heading-hover-color: $inverse-global-primary-background !default; - - - -// @mixin hook-inverse-link-muted(){} -// @mixin hook-inverse-link-muted-hover(){} -// @mixin hook-inverse-link-text-hover(){} -// @mixin hook-inverse-link-heading-hover(){} diff --git a/docs/_sass/uikit/components/list.scss b/docs/_sass/uikit/components/list.scss deleted file mode 100644 index bc66eb3b50..0000000000 --- a/docs/_sass/uikit/components/list.scss +++ /dev/null @@ -1,235 +0,0 @@ -// Name: List -// Description: Styles for lists -// -// Component: `uk-list` -// -// Modifiers: `uk-list-disc` -// `uk-list-circle` -// `uk-list-square` -// `uk-list-decimal` -// `uk-list-hyphen` -// `uk-list-muted` -// `uk-list-emphasis` -// `uk-list-primary` -// `uk-list-secondary` -// `uk-list-bullet` -// `uk-list-divider` -// `uk-list-striped` -// `uk-list-large` -// `uk-list-collapse` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$list-margin-top: $global-small-margin !default; - -$list-padding-left: 30px !default; - -$list-marker-height: ($global-line-height * 1em) !default; - -$list-muted-color: $global-muted-color !default; -$list-emphasis-color: $global-emphasis-color !default; -$list-primary-color: $global-primary-background !default; -$list-secondary-color: $global-secondary-background !default; - -$list-bullet-icon-color: $global-color !default; - -$list-divider-margin-top: $global-small-margin !default; -$list-divider-border-width: $global-border-width !default; -$list-divider-border: $global-border !default; - -$list-striped-padding-vertical: $global-small-margin !default; -$list-striped-padding-horizontal: $global-small-margin !default; -$list-striped-background: $global-muted-background !default; - -$list-large-margin-top: $global-margin !default; -$list-large-divider-margin-top: $global-margin !default; -$list-large-striped-padding-vertical: $global-margin !default; -$list-large-striped-padding-horizontal: $global-small-margin !default; - -$internal-list-bullet-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%226%22%20height%3D%226%22%20viewBox%3D%220%200%206%206%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Ccircle%20fill%3D%22#000%22%20cx%3D%223%22%20cy%3D%223%22%20r%3D%223%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; - - -/* ======================================================================== - Component: List - ========================================================================== */ - -.uk-list { - padding: 0; - list-style: none; -} - -/* - * Remove margin from the last-child - */ - -.uk-list > * > :last-child { margin-bottom: 0; } - -/* - * Style - */ - -.uk-list > :nth-child(n+2), -.uk-list > * > ul { margin-top: $list-margin-top; } - - -/* Marker modifiers - * Moving `::marker` inside `::before` to style it differently - * To style the `::marker` is currently only supported in Firefox and Safari - ========================================================================== */ - -.uk-list-disc > *, -.uk-list-circle > *, -.uk-list-square > *, -.uk-list-decimal > *, -.uk-list-hyphen > * { padding-left: $list-padding-left; } - -/* - * Type modifiers - */ - -.uk-list-decimal { counter-reset: decimal; } -.uk-list-decimal > * { counter-increment: decimal; } - -[class*='uk-list'] > ::before { - content: ''; - position: relative; - left: (-$list-padding-left); - width: $list-padding-left; - height: $list-marker-height; - margin-bottom: (-$list-marker-height); - display: list-item; - list-style-position: inside; - text-align: right; -} - -.uk-list-disc > ::before { list-style-type: disc; } -.uk-list-circle > ::before { list-style-type: circle; } -.uk-list-square > ::before { list-style-type: square; } -.uk-list-decimal > ::before { content: counter(decimal, decimal) '\200A.\00A0'; } -.uk-list-hyphen > ::before { content: '–\00A0\00A0'; } - -/* - * Color modifiers - */ - -.uk-list-muted > ::before { color: $list-muted-color !important; } -.uk-list-emphasis > ::before { color: $list-emphasis-color !important; } -.uk-list-primary > ::before { color: $list-primary-color !important; } -.uk-list-secondary > ::before { color: $list-secondary-color !important; } - - -/* Image bullet modifier - ========================================================================== */ - -.uk-list-bullet > * { padding-left: $list-padding-left; } - -.uk-list-bullet > ::before { - content: ""; - position: relative; - left: (-$list-padding-left); - width: $list-padding-left; - height: $list-marker-height; - margin-bottom: (-$list-marker-height); - @include svg-fill($internal-list-bullet-image, "#000", $list-bullet-icon-color); - background-repeat: no-repeat; - background-position: 50% 50%; -} - - -/* Style modifiers - ========================================================================== */ - -/* - * Divider - */ - -.uk-list-divider > :nth-child(n+2) { - margin-top: $list-divider-margin-top; - padding-top: $list-divider-margin-top; - border-top: $list-divider-border-width solid $list-divider-border; - @if(mixin-exists(hook-list-divider)) {@include hook-list-divider();} -} - -/* - * Striped - */ - -.uk-list-striped > * { - padding: $list-striped-padding-vertical $list-striped-padding-horizontal; - @if(mixin-exists(hook-list-striped)) {@include hook-list-striped();} -} - -.uk-list-striped > :nth-of-type(odd) { background: $list-striped-background; } - -.uk-list-striped > :nth-child(n+2) { margin-top: 0; } - - -/* Size modifier - ========================================================================== */ - -.uk-list-large > :nth-child(n+2), -.uk-list-large > * > ul { margin-top: $list-large-margin-top; } - -.uk-list-collapse > :nth-child(n+2), -.uk-list-collapse > * > ul { margin-top: 0; } - -/* - * Divider - */ - -.uk-list-large.uk-list-divider > :nth-child(n+2) { - margin-top: $list-large-divider-margin-top; - padding-top: $list-large-divider-margin-top; -} - -.uk-list-collapse.uk-list-divider > :nth-child(n+2) { - margin-top: 0; - padding-top: 0; -} - -/* - * Striped - */ - -.uk-list-large.uk-list-striped > * { padding: $list-large-striped-padding-vertical $list-large-striped-padding-horizontal; } - -.uk-list-collapse.uk-list-striped > * { - padding-top: 0; - padding-bottom: 0; -} - -.uk-list-large.uk-list-striped > :nth-child(n+2), -.uk-list-collapse.uk-list-striped > :nth-child(n+2) { margin-top: 0; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-list-misc)) {@include hook-list-misc();} - -// @mixin hook-list-divider(){} -// @mixin hook-list-striped(){} -// @mixin hook-list-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-list-muted-color: $inverse-global-muted-color !default; -$inverse-list-emphasis-color: $inverse-global-emphasis-color !default; -$inverse-list-primary-color: $inverse-global-primary-background !default; -$inverse-list-secondary-color: $inverse-global-primary-background !default; - -$inverse-list-divider-border: $inverse-global-border !default; -$inverse-list-striped-background: $inverse-global-muted-background !default; - -$inverse-list-bullet-icon-color: $inverse-global-color !default; - - - -// @mixin hook-inverse-list-divider(){} -// @mixin hook-inverse-list-striped(){} diff --git a/docs/_sass/uikit/components/margin.scss b/docs/_sass/uikit/components/margin.scss deleted file mode 100644 index 87d948dc2a..0000000000 --- a/docs/_sass/uikit/components/margin.scss +++ /dev/null @@ -1,250 +0,0 @@ -// Name: Margin -// Description: Utilities for margins -// -// Component: `uk-margin-*` -// `uk-margin-small-*` -// `uk-margin-medium-*` -// `uk-margin-large-*` -// `uk-margin-xlarge-*` -// `uk-margin-remove-*` -// `uk-margin-auto-*` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$margin-margin: $global-margin !default; - -$margin-small-margin: $global-small-margin !default; - -$margin-medium-margin: $global-medium-margin !default; - -$margin-large-margin: $global-medium-margin !default; -$margin-large-margin-l: $global-large-margin !default; - -$margin-xlarge-margin: $global-large-margin !default; -$margin-xlarge-margin-l: $global-xlarge-margin !default; - - -/* ======================================================================== - Component: Margin - ========================================================================== */ - -/* - * Default - */ - -.uk-margin { margin-bottom: $margin-margin; } -* + .uk-margin { margin-top: $margin-margin !important; } - -.uk-margin-top { margin-top: $margin-margin !important; } -.uk-margin-bottom { margin-bottom: $margin-margin !important; } -.uk-margin-left { margin-left: $margin-margin !important; } -.uk-margin-right { margin-right: $margin-margin !important; } - - -/* Small - ========================================================================== */ - -.uk-margin-small { margin-bottom: $margin-small-margin; } -* + .uk-margin-small { margin-top: $margin-small-margin !important; } - -.uk-margin-small-top { margin-top: $margin-small-margin !important; } -.uk-margin-small-bottom { margin-bottom: $margin-small-margin !important; } -.uk-margin-small-left { margin-left: $margin-small-margin !important; } -.uk-margin-small-right { margin-right: $margin-small-margin !important; } - - -/* Medium - ========================================================================== */ - -.uk-margin-medium { margin-bottom: $margin-medium-margin; } -* + .uk-margin-medium { margin-top: $margin-medium-margin !important; } - -.uk-margin-medium-top { margin-top: $margin-medium-margin !important; } -.uk-margin-medium-bottom { margin-bottom: $margin-medium-margin !important; } -.uk-margin-medium-left { margin-left: $margin-medium-margin !important; } -.uk-margin-medium-right { margin-right: $margin-medium-margin !important; } - - -/* Large - ========================================================================== */ - -.uk-margin-large { margin-bottom: $margin-large-margin; } -* + .uk-margin-large { margin-top: $margin-large-margin !important; } - -.uk-margin-large-top { margin-top: $margin-large-margin !important; } -.uk-margin-large-bottom { margin-bottom: $margin-large-margin !important; } -.uk-margin-large-left { margin-left: $margin-large-margin !important; } -.uk-margin-large-right { margin-right: $margin-large-margin !important; } - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-margin-large { margin-bottom: $margin-large-margin-l; } - * + .uk-margin-large { margin-top: $margin-large-margin-l !important; } - - .uk-margin-large-top { margin-top: $margin-large-margin-l !important; } - .uk-margin-large-bottom { margin-bottom: $margin-large-margin-l !important; } - .uk-margin-large-left { margin-left: $margin-large-margin-l !important; } - .uk-margin-large-right { margin-right: $margin-large-margin-l !important; } - -} - - -/* XLarge - ========================================================================== */ - -.uk-margin-xlarge { margin-bottom: $margin-xlarge-margin; } -* + .uk-margin-xlarge { margin-top: $margin-xlarge-margin !important; } - -.uk-margin-xlarge-top { margin-top: $margin-xlarge-margin !important; } -.uk-margin-xlarge-bottom { margin-bottom: $margin-xlarge-margin !important; } -.uk-margin-xlarge-left { margin-left: $margin-xlarge-margin !important; } -.uk-margin-xlarge-right { margin-right: $margin-xlarge-margin !important; } - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-margin-xlarge { margin-bottom: $margin-xlarge-margin-l; } - * + .uk-margin-xlarge { margin-top: $margin-xlarge-margin-l !important; } - - .uk-margin-xlarge-top { margin-top: $margin-xlarge-margin-l !important; } - .uk-margin-xlarge-bottom { margin-bottom: $margin-xlarge-margin-l !important; } - .uk-margin-xlarge-left { margin-left: $margin-xlarge-margin-l !important; } - .uk-margin-xlarge-right { margin-right: $margin-xlarge-margin-l !important; } - -} - - -/* Auto - ========================================================================== */ - -.uk-margin-auto { - margin-left: auto !important; - margin-right: auto !important; -} - -.uk-margin-auto-top { margin-top: auto !important; } -.uk-margin-auto-bottom { margin-bottom: auto !important; } -.uk-margin-auto-left { margin-left: auto !important; } -.uk-margin-auto-right { margin-right: auto !important; } - -.uk-margin-auto-vertical { - margin-top: auto !important; - margin-bottom: auto !important; -} - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-margin-auto\@s { - margin-left: auto !important; - margin-right: auto !important; - } - - .uk-margin-auto-left\@s { margin-left: auto !important; } - .uk-margin-auto-right\@s { margin-right: auto !important; } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-margin-auto\@m { - margin-left: auto !important; - margin-right: auto !important; - } - - .uk-margin-auto-left\@m { margin-left: auto !important; } - .uk-margin-auto-right\@m { margin-right: auto !important; } - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-margin-auto\@l { - margin-left: auto !important; - margin-right: auto !important; - } - - .uk-margin-auto-left\@l { margin-left: auto !important; } - .uk-margin-auto-right\@l { margin-right: auto !important; } - -} - -/* Large screen and bigger */ -@media (min-width: $breakpoint-xlarge) { - - .uk-margin-auto\@xl { - margin-left: auto !important; - margin-right: auto !important; - } - - .uk-margin-auto-left\@xl { margin-left: auto !important; } - .uk-margin-auto-right\@xl { margin-right: auto !important; } - -} - - -/* Remove - ========================================================================== */ - - .uk-margin-remove { margin: 0 !important; } - .uk-margin-remove-top { margin-top: 0 !important; } - .uk-margin-remove-bottom { margin-bottom: 0 !important; } - .uk-margin-remove-left { margin-left: 0 !important; } - .uk-margin-remove-right { margin-right: 0 !important; } - - .uk-margin-remove-vertical { - margin-top: 0 !important; - margin-bottom: 0 !important; - } - - .uk-margin-remove-adjacent + *, - .uk-margin-remove-first-child > :first-child { margin-top: 0 !important; } - .uk-margin-remove-last-child > :last-child { margin-bottom: 0 !important; } - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-margin-remove-left\@s { margin-left: 0 !important; } - .uk-margin-remove-right\@s { margin-right: 0 !important; } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-margin-remove-left\@m { margin-left: 0 !important; } - .uk-margin-remove-right\@m { margin-right: 0 !important; } - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-margin-remove-left\@l { margin-left: 0 !important; } - .uk-margin-remove-right\@l { margin-right: 0 !important; } - -} - -/* Large screen and bigger */ -@media (min-width: $breakpoint-xlarge) { - - .uk-margin-remove-left\@xl { margin-left: 0 !important; } - .uk-margin-remove-right\@xl { margin-right: 0 !important; } - -} - - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-margin-misc)) {@include hook-margin-misc();} - -// @mixin hook-margin-misc(){} diff --git a/docs/_sass/uikit/components/marker.scss b/docs/_sass/uikit/components/marker.scss deleted file mode 100644 index 97e436098c..0000000000 --- a/docs/_sass/uikit/components/marker.scss +++ /dev/null @@ -1,63 +0,0 @@ -// Name: Marker -// Description: Component to create a marker icon -// -// Component: `uk-marker` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$marker-padding: 5px !default; -$marker-background: $global-secondary-background !default; -$marker-color: $global-inverse-color !default; - -$marker-hover-color: $global-inverse-color !default; - - -/* ======================================================================== - Component: Marker - ========================================================================== */ - -/* - * Addopts `uk-icon` - */ - -.uk-marker { - padding: $marker-padding; - background: $marker-background; - color: $marker-color; - @if(mixin-exists(hook-marker)) {@include hook-marker();} -} - -/* Hover + Focus */ -.uk-marker:hover, -.uk-marker:focus { - color: $marker-hover-color; - outline: none; - @if(mixin-exists(hook-marker-hover)) {@include hook-marker-hover();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-marker-misc)) {@include hook-marker-misc();} - -// @mixin hook-marker(){} -// @mixin hook-marker-hover(){} -// @mixin hook-marker-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-marker-background: $global-muted-background !default; -$inverse-marker-color: $global-color !default; -$inverse-marker-hover-color: $global-color !default; - - - -// @mixin hook-inverse-marker(){} -// @mixin hook-inverse-marker-hover(){} diff --git a/docs/_sass/uikit/components/mixin.scss b/docs/_sass/uikit/components/mixin.scss deleted file mode 100644 index 5ed438a569..0000000000 --- a/docs/_sass/uikit/components/mixin.scss +++ /dev/null @@ -1,32 +0,0 @@ -// -// Component: Mixin -// Description: Defines mixins which are used across all components -// -// ======================================================================== - - -// SVG -// ======================================================================== - -/// Replace `$search` with `$replace` in `$string` -/// @author Hugo Giraudel -/// @param {String} $string - Initial string -/// @param {String} $search - Substring to replace -/// @param {String} $replace ('') - New value -/// @return {String} - Updated string -@function str-replace($string, $search, $replace: '') { - $index: str-index($string, $search); - - @if $index { - @return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace); - } - - @return $string; -} - -@mixin svg-fill($src, $color-default, $color-new){ - - $replace-src: str-replace($src, $color-default, $color-new) !default; - $replace-src: str-replace($replace-src, "#", "%23"); - background-image: url(https://github.com/quote($replace-src)); -} \ No newline at end of file diff --git a/docs/_sass/uikit/components/modal.scss b/docs/_sass/uikit/components/modal.scss deleted file mode 100644 index e93c99ca5d..0000000000 --- a/docs/_sass/uikit/components/modal.scss +++ /dev/null @@ -1,353 +0,0 @@ -// Name: Modal -// Description: Component to create modal dialogs -// -// Component: `uk-modal` -// -// Sub-objects: `uk-modal-page` -// `uk-modal-dialog` -// `uk-modal-header` -// `uk-modal-body` -// `uk-modal-footer` -// `uk-modal-title` -// `uk-modal-close` -// -// Adopted: `uk-modal-close-default` -// `uk-modal-close-outside` -// `uk-modal-close-full` -// -// Modifiers: `uk-modal-container` -// `uk-modal-full` -// -// States: `uk-open` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$modal-z-index: $global-z-index + 10 !default; -$modal-background: rgba(0,0,0,0.6) !default; - -$modal-padding-horizontal: 15px !default; -$modal-padding-horizontal-s: $global-gutter !default; -$modal-padding-horizontal-m: $global-medium-gutter !default; -$modal-padding-vertical: $modal-padding-horizontal !default; -$modal-padding-vertical-s: 50px !default; - -$modal-dialog-width: 600px !default; -$modal-dialog-background: $global-background !default; - -$modal-container-width: 1200px !default; - -$modal-body-padding-horizontal: $global-gutter !default; -$modal-body-padding-vertical: $global-gutter !default; - -$modal-header-padding-horizontal: $global-gutter !default; -$modal-header-padding-vertical: ($modal-header-padding-horizontal / 2) !default; -$modal-header-background: $global-muted-background !default; - -$modal-footer-padding-horizontal: $global-gutter !default; -$modal-footer-padding-vertical: ($modal-footer-padding-horizontal / 2) !default; -$modal-footer-background: $global-muted-background !default; - -$modal-title-font-size: $global-xlarge-font-size !default; -$modal-title-line-height: 1.3 !default; - -$modal-close-position: $global-small-margin !default; -$modal-close-padding: 5px !default; - -$modal-close-outside-position: 0 !default; -$modal-close-outside-translate: 100% !default; -$modal-close-outside-color: lighten($global-inverse-color, 20%) !default; -$modal-close-outside-hover-color: $global-inverse-color !default; - - -/* ======================================================================== - Component: Modal - ========================================================================== */ - -/* - * 1. Hide by default - * 2. Set position - * 3. Allow scrolling for the modal dialog - * 4. Horizontal padding - * 5. Mask the background page - * 6. Fade-in transition - */ - -.uk-modal { - /* 1 */ - display: none; - /* 2 */ - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: $modal-z-index; - /* 3 */ - overflow-y: auto; - -webkit-overflow-scrolling: touch; - /* 4 */ - padding: $modal-padding-vertical $modal-padding-horizontal; - /* 5 */ - background: $modal-background; - /* 6 */ - opacity: 0; - transition: opacity 0.15s linear; - @if(mixin-exists(hook-modal)) {@include hook-modal();} -} - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-modal { padding: $modal-padding-vertical-s $modal-padding-horizontal-s; } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-modal { - padding-left: $modal-padding-horizontal-m; - padding-right: $modal-padding-horizontal-m; - } - -} - -/* - * Open - */ - -.uk-modal.uk-open { opacity: 1; } - - -/* Page - ========================================================================== */ - -/* - * Prevent scrollbars - */ - -.uk-modal-page { overflow: hidden; } - - -/* Dialog - ========================================================================== */ - -/* - * 1. Create position context for spinner and close button - * 2. Dimensions - * 3. Fix `max-width: 100%` not working in combination with flex and responsive images in IE11 - * `!important` needed to overwrite `uk-width-auto`. See `#modal-media-image` in tests - * 4. Style - * 5. Slide-in transition - */ - -.uk-modal-dialog { - /* 1 */ - position: relative; - /* 2 */ - box-sizing: border-box; - margin: 0 auto; - width: $modal-dialog-width; - /* 3 */ - max-width: unquote('calc(100% - 0.01px)') !important; - /* 4 */ - background: $modal-dialog-background; - /* 5 */ - opacity: 0; - transform: translateY(-100px); - transition: 0.3s linear; - transition-property: opacity, transform; - @if(mixin-exists(hook-modal-dialog)) {@include hook-modal-dialog();} -} - -/* - * Open - */ - -.uk-open > .uk-modal-dialog { - opacity: 1; - transform: translateY(0); -} - - -/* Size modifier - ========================================================================== */ - -/* - * Container size - * Take the same size as the Container component - */ - -.uk-modal-container .uk-modal-dialog { width: $modal-container-width; } - -/* - * Full size - * 1. Remove padding and background from modal - * 2. Reset all default declarations from modal dialog - */ - -/* 1 */ -.uk-modal-full { - padding: 0; - background: none; -} - -/* 2 */ -.uk-modal-full .uk-modal-dialog { - margin: 0; - width: 100%; - max-width: 100%; - transform: translateY(0); - @if(mixin-exists(hook-modal-full)) {@include hook-modal-full();} -} - - -/* Sections - ========================================================================== */ - -.uk-modal-body { - display: flow-root; - padding: $modal-body-padding-vertical $modal-body-padding-horizontal; - @if(mixin-exists(hook-modal-body)) {@include hook-modal-body();} -} - -.uk-modal-header { - display: flow-root; - padding: $modal-header-padding-vertical $modal-header-padding-horizontal; - background: $modal-header-background; - @if(mixin-exists(hook-modal-header)) {@include hook-modal-header();} -} - -.uk-modal-footer { - display: flow-root; - padding: $modal-footer-padding-vertical $modal-footer-padding-horizontal; - background: $modal-footer-background; - @if(mixin-exists(hook-modal-footer)) {@include hook-modal-footer();} -} - -/* - * Remove margin from the last-child - */ - -.uk-modal-body > :last-child, -.uk-modal-header > :last-child, -.uk-modal-footer > :last-child { margin-bottom: 0; } - - -/* Title - ========================================================================== */ - -.uk-modal-title { - font-size: $modal-title-font-size; - line-height: $modal-title-line-height; - @if(mixin-exists(hook-modal-title)) {@include hook-modal-title();} -} - - -/* Close - * Adopts `uk-close` - ========================================================================== */ - -[class*='uk-modal-close-'] { - position: absolute; - z-index: $modal-z-index; - top: $modal-close-position; - right: $modal-close-position; - padding: $modal-close-padding; - @if(mixin-exists(hook-modal-close)) {@include hook-modal-close();} -} - -/* - * Remove margin from adjacent element - */ - -[class*='uk-modal-close-']:first-child + * { margin-top: 0; } - -/* - * Hover - */ - -[class*='uk-modal-close-']:hover { - @if(mixin-exists(hook-modal-close-hover)) {@include hook-modal-close-hover();} -} - -/* - * Default - */ - -.uk-modal-close-default { - @if(mixin-exists(hook-modal-close-default)) {@include hook-modal-close-default();} -} - -.uk-modal-close-default:hover { - @if(mixin-exists(hook-modal-close-default-hover)) {@include hook-modal-close-default-hover();} -} - -/* - * Outside - * 1. Prevent scrollbar on small devices - */ - -.uk-modal-close-outside { - top: $modal-close-outside-position; - /* 1 */ - right: (-$modal-close-padding); - transform: translate(0, -($modal-close-outside-translate)); - color: $modal-close-outside-color; - @if(mixin-exists(hook-modal-close-outside)) {@include hook-modal-close-outside();} -} - -.uk-modal-close-outside:hover { - color: $modal-close-outside-hover-color; - @if(mixin-exists(hook-modal-close-outside-hover)) {@include hook-modal-close-outside-hover();} -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - /* 1 */ - .uk-modal-close-outside { - right: $modal-close-outside-position; - transform: translate($modal-close-outside-translate, -($modal-close-outside-translate)); - } - -} - -/* - * Full - */ - -.uk-modal-close-full { - @if(mixin-exists(hook-modal-close-full)) {@include hook-modal-close-full();} -} - -.uk-modal-close-full:hover { - @if(mixin-exists(hook-modal-close-full-hover)) {@include hook-modal-close-full-hover();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-modal-misc)) {@include hook-modal-misc();} - -// @mixin hook-modal(){} -// @mixin hook-modal-dialog(){} -// @mixin hook-modal-full(){} -// @mixin hook-modal-header(){} -// @mixin hook-modal-body(){} -// @mixin hook-modal-footer(){} -// @mixin hook-modal-title(){} -// @mixin hook-modal-close(){} -// @mixin hook-modal-close-hover(){} -// @mixin hook-modal-close-default(){} -// @mixin hook-modal-close-default-hover(){} -// @mixin hook-modal-close-outside(){} -// @mixin hook-modal-close-outside-hover(){} -// @mixin hook-modal-close-full(){} -// @mixin hook-modal-close-full-hover(){} -// @mixin hook-modal-misc(){} diff --git a/docs/_sass/uikit/components/nav.scss b/docs/_sass/uikit/components/nav.scss deleted file mode 100644 index 78b0f6015d..0000000000 --- a/docs/_sass/uikit/components/nav.scss +++ /dev/null @@ -1,406 +0,0 @@ -// Name: Nav -// Description: Defines styles for list navigations -// -// Component: `uk-nav` -// -// Sub-objects: `uk-nav-header` -// `uk-nav-divider` -// `uk-nav-sub` -// -// Modifiers: `uk-nav-parent-icon` -// `uk-nav-default` -// `uk-nav-primary` -// `uk-nav-center`, -// `uk-nav-divider` -// -// States: `uk-active` -// `uk-parent` -// `uk-open` -// `uk-touch` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$nav-item-padding-vertical: 5px !default; -$nav-item-padding-horizontal: 0 !default; - -$nav-sublist-padding-vertical: 5px !default; -$nav-sublist-padding-left: 15px !default; -$nav-sublist-deeper-padding-left: 15px !default; -$nav-sublist-item-padding-vertical: 2px !default; - -$nav-parent-icon-width: ($global-line-height * 1em) !default; -$nav-parent-icon-height: $nav-parent-icon-width !default; -$nav-parent-icon-color: $global-color !default; - -$nav-header-padding-vertical: $nav-item-padding-vertical !default; -$nav-header-padding-horizontal: $nav-item-padding-horizontal !default; -$nav-header-font-size: $global-small-font-size !default; -$nav-header-text-transform: uppercase !default; -$nav-header-margin-top: $global-margin !default; - -$nav-divider-margin-vertical: 5px !default; -$nav-divider-margin-horizontal: 0 !default; - -$nav-default-item-color: $global-muted-color !default; -$nav-default-item-hover-color: $global-color !default; -$nav-default-item-active-color: $global-emphasis-color !default; -$nav-default-header-color: $global-emphasis-color !default; -$nav-default-divider-border-width: $global-border-width !default; -$nav-default-divider-border: $global-border !default; -$nav-default-sublist-item-color: $global-muted-color !default; -$nav-default-sublist-item-hover-color: $global-color !default; -$nav-default-sublist-item-active-color: $global-emphasis-color !default; - -$nav-primary-item-font-size: $global-large-font-size !default; -$nav-primary-item-line-height: $global-line-height !default; -$nav-primary-item-color: $global-muted-color !default; -$nav-primary-item-hover-color: $global-color !default; -$nav-primary-item-active-color: $global-emphasis-color !default; -$nav-primary-header-color: $global-emphasis-color !default; -$nav-primary-divider-border-width: $global-border-width !default; -$nav-primary-divider-border: $global-border !default; -$nav-primary-sublist-item-color: $global-muted-color !default; -$nav-primary-sublist-item-hover-color: $global-color !default; -$nav-primary-sublist-item-active-color: $global-emphasis-color !default; - -$nav-dividers-margin-top: 0 !default; -$nav-dividers-border-width: $global-border-width !default; -$nav-dividers-border: $global-border !default; - -$internal-nav-parent-close-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolyline%20fill%3D%22none%22%20stroke%3D%22#000%22%20stroke-width%3D%221.1%22%20points%3D%2210%201%204%207%2010%2013%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$internal-nav-parent-open-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolyline%20fill%3D%22none%22%20stroke%3D%22#000%22%20stroke-width%3D%221.1%22%20points%3D%221%204%207%2010%2013%204%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; - - -/* ======================================================================== - Component: Nav - ========================================================================== */ - -/* - * Reset - */ - -.uk-nav, -.uk-nav ul { - margin: 0; - padding: 0; - list-style: none; -} - -/* -* 1. Center content vertically, e.g. an icon -* 2. Imitate white space gap when using flexbox -* 3. Reset link -* 4. Space is allocated solely based on content dimensions: 0 0 auto - */ - -.uk-nav li > a { - /* 1 */ - display: flex; - align-items: center; - /* 2 */ - column-gap: 0.25em; - /* 3*/ - text-decoration: none; -} - -/* 4 */ -.uk-nav li > a > * { flex: none; } - -/* - * Remove default focus style - */ - -.uk-nav li > a:focus { outline: none; } - -/* - * Items - * Must target `a` elements to exclude other elements (e.g. lists) - */ - -.uk-nav > li > a { padding: $nav-item-padding-vertical $nav-item-padding-horizontal; } - - -/* Sublists - ========================================================================== */ - -/* - * Level 2 - * `ul` needed for higher specificity to override padding - */ - -ul.uk-nav-sub { - padding: $nav-sublist-padding-vertical 0 $nav-sublist-padding-vertical $nav-sublist-padding-left; - @if(mixin-exists(hook-nav-sub)) {@include hook-nav-sub();} -} - -/* - * Level 3 and deeper - */ - -.uk-nav-sub ul { padding-left: $nav-sublist-deeper-padding-left; } - -/* - * Items - */ - -.uk-nav-sub a { padding: $nav-sublist-item-padding-vertical 0; } - - -/* Parent icon modifier - ========================================================================== */ - -.uk-nav-parent-icon > .uk-parent > a::after { - content: ""; - width: $nav-parent-icon-width; - height: $nav-parent-icon-height; - margin-left: auto; - @include svg-fill($internal-nav-parent-close-image, "#000", $nav-parent-icon-color); - background-repeat: no-repeat; - background-position: 50% 50%; - @if(mixin-exists(hook-nav-parent-icon)) {@include hook-nav-parent-icon();} -} - -.uk-nav-parent-icon > .uk-parent.uk-open > a::after { @include svg-fill($internal-nav-parent-open-image, "#000", $nav-parent-icon-color); } - - -/* Header - ========================================================================== */ - -.uk-nav-header { - padding: $nav-header-padding-vertical $nav-header-padding-horizontal; - text-transform: $nav-header-text-transform; - font-size: $nav-header-font-size; - @if(mixin-exists(hook-nav-header)) {@include hook-nav-header();} -} - -.uk-nav-header:not(:first-child) { margin-top: $nav-header-margin-top; } - - -/* Divider - ========================================================================== */ - -.uk-nav >.uk-nav-divider { - margin: $nav-divider-margin-vertical $nav-divider-margin-horizontal; - @if(mixin-exists(hook-nav-divider)) {@include hook-nav-divider();} -} - - -/* Default modifier - ========================================================================== */ - -.uk-nav-default { - @if(mixin-exists(hook-nav-default)) {@include hook-nav-default();} -} - -/* - * Items - */ - -.uk-nav-default > li > a { - color: $nav-default-item-color; - @if(mixin-exists(hook-nav-default-item)) {@include hook-nav-default-item();} -} - -/* Hover + Focus */ -.uk-nav-default > li > a:hover, -.uk-nav-default > li > a:focus { - color: $nav-default-item-hover-color; - @if(mixin-exists(hook-nav-default-item-hover)) {@include hook-nav-default-item-hover();} -} - -/* Active */ -.uk-nav-default > li.uk-active > a { - color: $nav-default-item-active-color; - @if(mixin-exists(hook-nav-default-item-active)) {@include hook-nav-default-item-active();} -} - -/* - * Header - */ - -.uk-nav-default .uk-nav-header { - color: $nav-default-header-color; - @if(mixin-exists(hook-nav-default-header)) {@include hook-nav-default-header();} -} - -/* - * Divider - */ - -.uk-nav-default .uk-nav-divider { - border-top: $nav-default-divider-border-width solid $nav-default-divider-border; - @if(mixin-exists(hook-nav-default-divider)) {@include hook-nav-default-divider();} -} - -/* - * Sublists - */ - -.uk-nav-default .uk-nav-sub a { color: $nav-default-sublist-item-color; } - -.uk-nav-default .uk-nav-sub a:hover, -.uk-nav-default .uk-nav-sub a:focus { color: $nav-default-sublist-item-hover-color; } - -.uk-nav-default .uk-nav-sub li.uk-active > a { color: $nav-default-sublist-item-active-color; } - - -/* Primary modifier - ========================================================================== */ - -.uk-nav-primary { - @if(mixin-exists(hook-nav-primary)) {@include hook-nav-primary();} -} - -/* - * Items - */ - -.uk-nav-primary > li > a { - font-size: $nav-primary-item-font-size; - line-height: $nav-primary-item-line-height; - color: $nav-primary-item-color; - @if(mixin-exists(hook-nav-primary-item)) {@include hook-nav-primary-item();} -} - -/* Hover + Focus */ -.uk-nav-primary > li > a:hover, -.uk-nav-primary > li > a:focus { - color: $nav-primary-item-hover-color; - @if(mixin-exists(hook-nav-primary-item-hover)) {@include hook-nav-primary-item-hover();} -} - -/* Active */ -.uk-nav-primary > li.uk-active > a { - color: $nav-primary-item-active-color; - @if(mixin-exists(hook-nav-primary-item-active)) {@include hook-nav-primary-item-active();} -} - -/* - * Header - */ - -.uk-nav-primary .uk-nav-header { - color: $nav-primary-header-color; - @if(mixin-exists(hook-nav-primary-header)) {@include hook-nav-primary-header();} -} - -/* - * Divider - */ - -.uk-nav-primary .uk-nav-divider { - border-top: $nav-primary-divider-border-width solid $nav-primary-divider-border; - @if(mixin-exists(hook-nav-primary-divider)) {@include hook-nav-primary-divider();} -} - -/* - * Sublists - */ - -.uk-nav-primary .uk-nav-sub a { color: $nav-primary-sublist-item-color; } - -.uk-nav-primary .uk-nav-sub a:hover, -.uk-nav-primary .uk-nav-sub a:focus { color: $nav-primary-sublist-item-hover-color; } - -.uk-nav-primary .uk-nav-sub li.uk-active > a { color: $nav-primary-sublist-item-active-color; } - - -/* Alignment modifier - ========================================================================== */ - -/* - * 1. Center header - * 2. Center items - */ - - /* 1 */ -.uk-nav-center { text-align: center; } - /* 2 */ -.uk-nav-center li > a { justify-content: center; } - -/* Sublists */ -.uk-nav-center .uk-nav-sub, -.uk-nav-center .uk-nav-sub ul { padding-left: 0; } - -/* Parent icon modifier */ -.uk-nav-center.uk-nav-parent-icon > .uk-parent > a::after { margin-left: 0; } - - -/* Style modifier - ========================================================================== */ - -.uk-nav.uk-nav-divider > :not(.uk-nav-divider) + :not(.uk-nav-header, .uk-nav-divider) { - margin-top: $nav-dividers-margin-top; - padding-top: $nav-dividers-margin-top; - border-top: $nav-dividers-border-width solid $nav-dividers-border; - @if(mixin-exists(hook-nav-dividers)) {@include hook-nav-dividers();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-nav-misc)) {@include hook-nav-misc();} - -// @mixin hook-nav-sub(){} -// @mixin hook-nav-parent-icon(){} -// @mixin hook-nav-header(){} -// @mixin hook-nav-divider(){} -// @mixin hook-nav-default(){} -// @mixin hook-nav-default-item(){} -// @mixin hook-nav-default-item-hover(){} -// @mixin hook-nav-default-item-active(){} -// @mixin hook-nav-default-header(){} -// @mixin hook-nav-default-divider(){} -// @mixin hook-nav-primary(){} -// @mixin hook-nav-primary-item(){} -// @mixin hook-nav-primary-item-hover(){} -// @mixin hook-nav-primary-item-active(){} -// @mixin hook-nav-primary-header(){} -// @mixin hook-nav-primary-divider(){} -// @mixin hook-nav-dividers(){} -// @mixin hook-nav-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-nav-parent-icon-color: $inverse-global-color !default; -$inverse-nav-default-item-color: $inverse-global-muted-color !default; -$inverse-nav-default-item-hover-color: $inverse-global-color !default; -$inverse-nav-default-item-active-color: $inverse-global-emphasis-color !default; -$inverse-nav-default-header-color: $inverse-global-emphasis-color !default; -$inverse-nav-default-divider-border: $inverse-global-border !default; -$inverse-nav-default-sublist-item-color: $inverse-global-muted-color !default; -$inverse-nav-default-sublist-item-hover-color: $inverse-global-color !default; -$inverse-nav-default-sublist-item-active-color: $inverse-global-emphasis-color !default; - -$inverse-nav-primary-item-color: $inverse-global-muted-color !default; -$inverse-nav-primary-item-hover-color: $inverse-global-color !default; -$inverse-nav-primary-item-active-color: $inverse-global-emphasis-color !default; -$inverse-nav-primary-header-color: $inverse-global-emphasis-color !default; -$inverse-nav-primary-divider-border: $inverse-global-border !default; -$inverse-nav-primary-sublist-item-color: $inverse-global-muted-color !default; -$inverse-nav-primary-sublist-item-hover-color: $inverse-global-color !default; -$inverse-nav-primary-sublist-item-active-color: $inverse-global-emphasis-color !default; - -$inverse-nav-dividers-border: $inverse-global-border !default; - - - -// @mixin hook-inverse-nav-parent-icon(){} -// @mixin hook-inverse-nav-default-item(){} -// @mixin hook-inverse-nav-default-item-hover(){} -// @mixin hook-inverse-nav-default-item-active(){} -// @mixin hook-inverse-nav-default-header(){} -// @mixin hook-inverse-nav-default-divider(){} -// @mixin hook-inverse-nav-primary-item(){} -// @mixin hook-inverse-nav-primary-item-hover(){} -// @mixin hook-inverse-nav-primary-item-active(){} -// @mixin hook-inverse-nav-primary-header(){} -// @mixin hook-inverse-nav-primary-divider(){} -// @mixin hook-inverse-nav-dividers(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/navbar.scss b/docs/_sass/uikit/components/navbar.scss deleted file mode 100644 index 3cfd6a3a62..0000000000 --- a/docs/_sass/uikit/components/navbar.scss +++ /dev/null @@ -1,554 +0,0 @@ -// Name: Navbar -// Description: Component to create horizontal navigation bars -// -// Component: `uk-navbar` -// -// Sub-objects: `uk-navbar-container` -// `uk-navbar-left` -// `uk-navbar-right` -// `uk-navbar-center` -// `uk-navbar-center-left` -// `uk-navbar-center-right` -// `uk-navbar-nav` -// `uk-navbar-item` -// `uk-navbar-toggle` -// `uk-navbar-subtitle` -// `uk-navbar-dropbar` -// -// Adopted: `uk-navbar-dropdown` + Modifiers -// `uk-navbar-dropdown-nav` -// `uk-navbar-dropdown-grid` -// `uk-navbar-toggle-icon` -// -// Modifiers: `uk-navbar-primary` -// `uk-navbar-transparent` -// `uk-navbar-sticky` -// `uk-navbar-dropdown-stack` -// -// States: `uk-active` -// `uk-parent` -// `uk-open` -// -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$navbar-background: $global-muted-background !default; -$navbar-color-mode: none !default; - -$navbar-nav-item-height: 80px !default; -$navbar-nav-item-padding-horizontal: 15px !default; -$navbar-nav-item-color: $global-muted-color !default; -$navbar-nav-item-font-size: $global-font-size !default; -$navbar-nav-item-font-family: $global-font-family !default; -$navbar-nav-item-hover-color: $global-color !default; -$navbar-nav-item-onclick-color: $global-emphasis-color !default; -$navbar-nav-item-active-color: $global-emphasis-color !default; - -$navbar-item-color: $global-color !default; - -$navbar-toggle-color: $global-muted-color !default; -$navbar-toggle-hover-color: $global-color !default; - -$navbar-subtitle-font-size: $global-small-font-size !default; - -$navbar-dropdown-z-index: $global-z-index + 20 !default; -$navbar-dropdown-width: 200px !default; -$navbar-dropdown-margin: 0 !default; -$navbar-dropdown-padding: 15px !default; -$navbar-dropdown-background: $global-muted-background !default; -$navbar-dropdown-color: $global-color !default; -$navbar-dropdown-grid-gutter-horizontal: $global-gutter !default; -$navbar-dropdown-grid-gutter-vertical: $navbar-dropdown-grid-gutter-horizontal !default; - -$navbar-dropdown-dropbar-margin-top: 0 !default; -$navbar-dropdown-dropbar-margin-bottom: $navbar-dropdown-dropbar-margin-top !default; - -$navbar-dropdown-nav-item-color: $global-muted-color !default; -$navbar-dropdown-nav-item-hover-color: $global-color !default; -$navbar-dropdown-nav-item-active-color: $global-emphasis-color !default; -$navbar-dropdown-nav-header-color: $global-emphasis-color !default; -$navbar-dropdown-nav-divider-border-width: $global-border-width !default; -$navbar-dropdown-nav-divider-border: $global-border !default; -$navbar-dropdown-nav-sublist-item-color: $global-muted-color !default; -$navbar-dropdown-nav-sublist-item-hover-color: $global-color !default; -$navbar-dropdown-nav-sublist-item-active-color: $global-emphasis-color !default; - -$navbar-dropbar-background: $navbar-dropdown-background !default; -$navbar-dropbar-z-index: $global-z-index - 20 !default; - - -/* ======================================================================== - Component: Navbar - ========================================================================== */ - -/* - * 1. Create position context to center navbar group - */ - -.uk-navbar { - display: flex; - /* 1 */ - position: relative; - @if(mixin-exists(hook-navbar)) {@include hook-navbar();} -} - - -/* Container - ========================================================================== */ - -.uk-navbar-container:not(.uk-navbar-transparent) { - background: $navbar-background; - @if(mixin-exists(hook-navbar-container)) {@include hook-navbar-container();} -} - -// Color Mode -@if ( $navbar-color-mode == light ) { .uk-navbar-container:not(.uk-navbar-transparent) { @extend .uk-light !optional;} } -@if ( $navbar-color-mode == dark ) { .uk-navbar-container:not(.uk-navbar-transparent) { @extend .uk-dark !optional;} } - -/* - * Remove pseudo elements created by micro clearfix as precaution (if Container component is used) - */ - -.uk-navbar-container > ::before, -.uk-navbar-container > ::after { display: none !important; } - - -/* Groups - ========================================================================== */ - -/* - * 1. Align navs and items vertically if they have a different height - * 2. Note: IE 11 requires an extra `div` which affects the center selector - */ - -.uk-navbar-left, -.uk-navbar-right, -// 2. [class*='uk-navbar-center'], -.uk-navbar-center, -.uk-navbar-center-left > *, -.uk-navbar-center-right > * { - display: flex; - /* 1 */ - align-items: center; -} - -/* - * Horizontal alignment - * 1. Create position context for centered navbar with sub groups (left/right) - * 2. Fix text wrapping if content is larger than 50% of the container. - * 3. Needed for dropdowns because a new position context is created - * `z-index` must be smaller than off-canvas - * 4. Align sub groups for centered navbar - */ - -.uk-navbar-right { margin-left: auto; } - -.uk-navbar-center:only-child { - margin-left: auto; - margin-right: auto; - /* 1 */ - position: relative; -} - -.uk-navbar-center:not(:only-child) { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%,-50%); - /* 2 */ - width: max-content; - box-sizing: border-box; - /* 3 */ - z-index: $global-z-index - 10; -} - -/* 4 */ -.uk-navbar-center-left, -.uk-navbar-center-right { - position: absolute; - top: 0; -} - -.uk-navbar-center-left { right: 100%; } -.uk-navbar-center-right { left: 100%; } - -[class*='uk-navbar-center-'] { - width: max-content; - box-sizing: border-box; -} - - -/* Nav - ========================================================================== */ - -/* - * 1. Reset list - */ - -.uk-navbar-nav { - display: flex; - /* 1 */ - margin: 0; - padding: 0; - list-style: none; -} - -/* - * Allow items to wrap into the next line - * Only not `absolute` positioned groups - */ - -.uk-navbar-left, -.uk-navbar-right, -.uk-navbar-center:only-child { flex-wrap: wrap; } - -/* - * Items - * 1. Center content vertically and horizontally - * 2. Imitate white space gap when using flexbox - * 3. Dimensions - * 4. Style - * 5. Required for `a` - */ - -.uk-navbar-nav > li > a, // Nav item -.uk-navbar-item, // Content item -.uk-navbar-toggle { // Clickable item - /* 1 */ - display: flex; - justify-content: center; - align-items: center; - /* 2 */ - column-gap: 0.25em; - /* 3 */ - box-sizing: border-box; - min-height: $navbar-nav-item-height; - padding: 0 $navbar-nav-item-padding-horizontal; - /* 4 */ - font-size: $navbar-nav-item-font-size; - font-family: $navbar-nav-item-font-family; - /* 5 */ - text-decoration: none; -} - -/* - * Nav items - */ - -.uk-navbar-nav > li > a { - color: $navbar-nav-item-color; - @if(mixin-exists(hook-navbar-nav-item)) {@include hook-navbar-nav-item();} -} - -/* - * Hover - * Apply hover style also to focus state and if dropdown is opened - */ - -.uk-navbar-nav > li:hover > a, -.uk-navbar-nav > li > a:focus, -.uk-navbar-nav > li > a.uk-open { - color: $navbar-nav-item-hover-color; - outline: none; - @if(mixin-exists(hook-navbar-nav-item-hover)) {@include hook-navbar-nav-item-hover();} -} - -/* OnClick */ -.uk-navbar-nav > li > a:active { - color: $navbar-nav-item-onclick-color; - @if(mixin-exists(hook-navbar-nav-item-onclick)) {@include hook-navbar-nav-item-onclick();} -} - -/* Active */ -.uk-navbar-nav > li.uk-active > a { - color: $navbar-nav-item-active-color; - @if(mixin-exists(hook-navbar-nav-item-active)) {@include hook-navbar-nav-item-active();} -} - - -/* Item - ========================================================================== */ - -.uk-navbar-item { - color: $navbar-item-color; - @if(mixin-exists(hook-navbar-item)) {@include hook-navbar-item();} -} - -/* - * Remove margin from the last-child - */ - -.uk-navbar-item > :last-child { margin-bottom: 0; } - - -/* Toggle - ========================================================================== */ - -.uk-navbar-toggle { - color: $navbar-toggle-color; - @if(mixin-exists(hook-navbar-toggle)) {@include hook-navbar-toggle();} -} - -.uk-navbar-toggle:hover, -.uk-navbar-toggle:focus, -.uk-navbar-toggle.uk-open { - color: $navbar-toggle-hover-color; - outline: none; - text-decoration: none; - @if(mixin-exists(hook-navbar-toggle-hover)) {@include hook-navbar-toggle-hover();} -} - -/* - * Icon - * Adopts `uk-icon` - */ - -.uk-navbar-toggle-icon { - @if(mixin-exists(hook-navbar-toggle-icon)) {@include hook-navbar-toggle-icon();} -} - -/* Hover + Focus */ -:hover > .uk-navbar-toggle-icon, -:focus > .uk-navbar-toggle-icon { - @if(mixin-exists(hook-navbar-toggle-icon-hover)) {@include hook-navbar-toggle-icon-hover();} -} - - -/* Subtitle - ========================================================================== */ - -.uk-navbar-subtitle { - font-size: $navbar-subtitle-font-size; - @if(mixin-exists(hook-navbar-subtitle)) {@include hook-navbar-subtitle();} -} - - -/* Style modifiers - ========================================================================== */ - -.uk-navbar-primary { - @if(mixin-exists(hook-navbar-primary)) {@include hook-navbar-primary();} -} - -.uk-navbar-transparent { - @if(mixin-exists(hook-navbar-transparent)) {@include hook-navbar-transparent();} -} - -.uk-navbar-sticky { - @if(mixin-exists(hook-navbar-sticky)) {@include hook-navbar-sticky();} -} - - -/* Dropdown - ========================================================================== */ - -/* - * Adopts `uk-dropdown` - * 1. Hide by default - * 2. Set position - * 3. Set a default width - * 4. Style - */ - -.uk-navbar-dropdown { - /* 1 */ - display: none; - /* 2 */ - position: absolute; - z-index: $navbar-dropdown-z-index; - /* 3 */ - box-sizing: border-box; - width: $navbar-dropdown-width; - /* 4 */ - padding: $navbar-dropdown-padding; - background: $navbar-dropdown-background; - color: $navbar-dropdown-color; - @if(mixin-exists(hook-navbar-dropdown)) {@include hook-navbar-dropdown();} -} - -/* Show */ -.uk-navbar-dropdown.uk-open { display: block; } - -/* - * Direction / Alignment modifiers - */ - -/* Direction */ -[class*='uk-navbar-dropdown-top'] { margin-top: (-$navbar-dropdown-margin); } -[class*='uk-navbar-dropdown-bottom'] { margin-top: $navbar-dropdown-margin; } -[class*='uk-navbar-dropdown-left'] { margin-left: (-$navbar-dropdown-margin); } -[class*='uk-navbar-dropdown-right'] { margin-left: $navbar-dropdown-margin; } - -/* - * Grid - * Adopts `uk-grid` - */ - -/* Gutter Horizontal */ -.uk-navbar-dropdown-grid { margin-left: (-$navbar-dropdown-grid-gutter-horizontal); } -.uk-navbar-dropdown-grid > * { padding-left: $navbar-dropdown-grid-gutter-horizontal; } - -/* Gutter Vertical */ -.uk-navbar-dropdown-grid > .uk-grid-margin { margin-top: $navbar-dropdown-grid-gutter-vertical; } - -/* Stack */ -.uk-navbar-dropdown-stack .uk-navbar-dropdown-grid > * { width: 100% !important; } - -/* - * Width modifier - */ - -.uk-navbar-dropdown-width-2:not(.uk-navbar-dropdown-stack) { width: ($navbar-dropdown-width * 2); } -.uk-navbar-dropdown-width-3:not(.uk-navbar-dropdown-stack) { width: ($navbar-dropdown-width * 3); } -.uk-navbar-dropdown-width-4:not(.uk-navbar-dropdown-stack) { width: ($navbar-dropdown-width * 4); } -.uk-navbar-dropdown-width-5:not(.uk-navbar-dropdown-stack) { width: ($navbar-dropdown-width * 5); } - -/* - * Dropbar modifier - */ - -.uk-navbar-dropdown-dropbar { - margin-top: $navbar-dropdown-dropbar-margin-top; - margin-bottom: $navbar-dropdown-dropbar-margin-bottom; - @if(mixin-exists(hook-navbar-dropdown-dropbar)) {@include hook-navbar-dropdown-dropbar();} -} - - -/* Dropdown Nav - * Adopts `uk-nav` - ========================================================================== */ - -.uk-navbar-dropdown-nav { - @if(mixin-exists(hook-navbar-dropdown-nav)) {@include hook-navbar-dropdown-nav();} -} - -/* - * Items - */ - -.uk-navbar-dropdown-nav > li > a { - color: $navbar-dropdown-nav-item-color; - @if(mixin-exists(hook-navbar-dropdown-nav-item)) {@include hook-navbar-dropdown-nav-item();} -} - -/* Hover + Focus */ -.uk-navbar-dropdown-nav > li > a:hover, -.uk-navbar-dropdown-nav > li > a:focus { - color: $navbar-dropdown-nav-item-hover-color; - @if(mixin-exists(hook-navbar-dropdown-nav-item-hover)) {@include hook-navbar-dropdown-nav-item-hover();} -} - -/* Active */ -.uk-navbar-dropdown-nav > li.uk-active > a { - color: $navbar-dropdown-nav-item-active-color; - @if(mixin-exists(hook-navbar-dropdown-nav-item-active)) {@include hook-navbar-dropdown-nav-item-active();} -} - -/* - * Header - */ - -.uk-navbar-dropdown-nav .uk-nav-header { - color: $navbar-dropdown-nav-header-color; - @if(mixin-exists(hook-navbar-dropdown-nav-header)) {@include hook-navbar-dropdown-nav-header();} -} - -/* - * Divider - */ - -.uk-navbar-dropdown-nav .uk-nav-divider { - border-top: $navbar-dropdown-nav-divider-border-width solid $navbar-dropdown-nav-divider-border; - @if(mixin-exists(hook-navbar-dropdown-nav-divider)) {@include hook-navbar-dropdown-nav-divider();} -} - -/* - * Sublists - */ - -.uk-navbar-dropdown-nav .uk-nav-sub a { color: $navbar-dropdown-nav-sublist-item-color; } - -.uk-navbar-dropdown-nav .uk-nav-sub a:hover, -.uk-navbar-dropdown-nav .uk-nav-sub a:focus { color: $navbar-dropdown-nav-sublist-item-hover-color; } - -.uk-navbar-dropdown-nav .uk-nav-sub li.uk-active > a { color: $navbar-dropdown-nav-sublist-item-active-color; } - - -/* Dropbar - ========================================================================== */ - -.uk-navbar-dropbar { - background: $navbar-dropbar-background; - @if(mixin-exists(hook-navbar-dropbar)) {@include hook-navbar-dropbar();} -} - -/* - * Slide modifier - */ - -.uk-navbar-dropbar-slide { - position: absolute; - z-index: $navbar-dropbar-z-index; - left: 0; - right: 0; - @if(mixin-exists(hook-navbar-dropbar-slide)) {@include hook-navbar-dropbar-slide();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-navbar-misc)) {@include hook-navbar-misc();} - -// @mixin hook-navbar(){} -// @mixin hook-navbar-container(){} -// @mixin hook-navbar-nav-item(){} -// @mixin hook-navbar-nav-item-hover(){} -// @mixin hook-navbar-nav-item-onclick(){} -// @mixin hook-navbar-nav-item-active(){} -// @mixin hook-navbar-item(){} -// @mixin hook-navbar-toggle(){} -// @mixin hook-navbar-toggle-hover(){} -// @mixin hook-navbar-toggle-icon(){} -// @mixin hook-navbar-toggle-icon-hover(){} -// @mixin hook-navbar-subtitle(){} -// @mixin hook-navbar-primary(){} -// @mixin hook-navbar-transparent(){} -// @mixin hook-navbar-sticky(){} -// @mixin hook-navbar-dropdown(){} -// @mixin hook-navbar-dropdown-dropbar(){} -// @mixin hook-navbar-dropdown-nav(){} -// @mixin hook-navbar-dropdown-nav-item(){} -// @mixin hook-navbar-dropdown-nav-item-hover(){} -// @mixin hook-navbar-dropdown-nav-item-active(){} -// @mixin hook-navbar-dropdown-nav-header(){} -// @mixin hook-navbar-dropdown-nav-divider(){} -// @mixin hook-navbar-dropbar(){} -// @mixin hook-navbar-dropbar-slide(){} -// @mixin hook-navbar-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-navbar-nav-item-color: $inverse-global-muted-color !default; -$inverse-navbar-nav-item-hover-color: $inverse-global-color !default; -$inverse-navbar-nav-item-onclick-color: $inverse-global-emphasis-color !default; -$inverse-navbar-nav-item-active-color: $inverse-global-emphasis-color !default; -$inverse-navbar-item-color: $inverse-global-color !default; -$inverse-navbar-toggle-color: $inverse-global-muted-color !default; -$inverse-navbar-toggle-hover-color: $inverse-global-color !default; - - - -// @mixin hook-inverse-navbar-nav-item(){} -// @mixin hook-inverse-navbar-nav-item-hover(){} -// @mixin hook-inverse-navbar-nav-item-onclick(){} -// @mixin hook-inverse-navbar-nav-item-active(){} -// @mixin hook-inverse-navbar-item(){} -// @mixin hook-inverse-navbar-toggle(){} -// @mixin hook-inverse-navbar-toggle-hover(){} diff --git a/docs/_sass/uikit/components/notification.scss b/docs/_sass/uikit/components/notification.scss deleted file mode 100644 index 37aff72963..0000000000 --- a/docs/_sass/uikit/components/notification.scss +++ /dev/null @@ -1,191 +0,0 @@ -// Name: Notification -// Description: Component to create notification messages -// -// Component: `uk-notification` -// -// Sub-objects: `uk-notification-message` -// -// Adopted: `uk-notification-close` -// -// Modifiers: `uk-notification-top-center` -// `uk-notification-top-right` -// `uk-notification-bottom-left` -// `uk-notification-bottom-center` -// `uk-notification-bottom-right` -// `uk-notification-message-primary` -// `uk-notification-message-success` -// `uk-notification-message-warning` -// `uk-notification-message-danger` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$notification-position: 10px !default; -$notification-z-index: $global-z-index + 40 !default; -$notification-width: 350px !default; - -$notification-message-margin-top: 10px !default; -$notification-message-padding: $global-small-gutter !default; -$notification-message-background: $global-muted-background !default; -$notification-message-color: $global-color !default; -$notification-message-font-size: $global-medium-font-size !default; -$notification-message-line-height: 1.4 !default; - -$notification-close-top: $notification-message-padding + 5px !default; -$notification-close-right: $notification-message-padding !default; - -$notification-message-primary-color: $global-primary-background !default; -$notification-message-success-color: $global-success-background !default; -$notification-message-warning-color: $global-warning-background !default; -$notification-message-danger-color: $global-danger-background !default; - - -/* ======================================================================== - Component: Notification - ========================================================================== */ - -/* - * 1. Set position - * 2. Dimensions - */ - -.uk-notification { - /* 1 */ - position: fixed; - top: $notification-position; - left: $notification-position; - z-index: $notification-z-index; - /* 2 */ - box-sizing: border-box; - width: $notification-width; - @if(mixin-exists(hook-notification)) {@include hook-notification();} -} - - -/* Position modifiers -========================================================================== */ - -.uk-notification-top-right, -.uk-notification-bottom-right { - left: auto; - right: $notification-position; -} - -.uk-notification-top-center, -.uk-notification-bottom-center { - left: 50%; - margin-left: ($notification-width / -2); -} - -.uk-notification-bottom-left, -.uk-notification-bottom-right, -.uk-notification-bottom-center { - top: auto; - bottom: $notification-position; -} - - -/* Responsiveness -========================================================================== */ - -/* Phones portrait and smaller */ -@media (max-width: $breakpoint-xsmall-max) { - - .uk-notification { - left: $notification-position; - right: $notification-position; - width: auto; - margin: 0; - } - -} - - -/* Message -========================================================================== */ - -.uk-notification-message { - position: relative; - padding: $notification-message-padding; - background: $notification-message-background; - color: $notification-message-color; - font-size: $notification-message-font-size; - line-height: $notification-message-line-height; - cursor: pointer; - @if(mixin-exists(hook-notification-message)) {@include hook-notification-message();} -} - -* + .uk-notification-message { margin-top: $notification-message-margin-top; } - - -/* Close - * Adopts `uk-close` - ========================================================================== */ - -.uk-notification-close { - display: none; - position: absolute; - top: $notification-close-top; - right: $notification-close-right; - @if(mixin-exists(hook-notification-close)) {@include hook-notification-close();} -} - -.uk-notification-message:hover .uk-notification-close { display: block; } - - -/* Style modifiers - ========================================================================== */ - -/* - * Primary - */ - -.uk-notification-message-primary { - color: $notification-message-primary-color; - @if(mixin-exists(hook-notification-message-primary)) {@include hook-notification-message-primary();} -} - -/* - * Success - */ - -.uk-notification-message-success { - color: $notification-message-success-color; - @if(mixin-exists(hook-notification-message-success)) {@include hook-notification-message-success();} -} - -/* - * Warning - */ - -.uk-notification-message-warning { - color: $notification-message-warning-color; - @if(mixin-exists(hook-notification-message-warning)) {@include hook-notification-message-warning();} -} - -/* - * Danger - */ - -.uk-notification-message-danger { - color: $notification-message-danger-color; - @if(mixin-exists(hook-notification-message-danger)) {@include hook-notification-message-danger();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-notification-misc)) {@include hook-notification-misc();} - -// @mixin hook-notification(){} -// @mixin hook-notification-message(){} -// @mixin hook-notification-close(){} -// @mixin hook-notification-message-primary(){} -// @mixin hook-notification-message-success(){} -// @mixin hook-notification-message-warning(){} -// @mixin hook-notification-message-danger(){} -// @mixin hook-notification-misc(){} diff --git a/docs/_sass/uikit/components/offcanvas.scss b/docs/_sass/uikit/components/offcanvas.scss deleted file mode 100644 index af5e7a67d4..0000000000 --- a/docs/_sass/uikit/components/offcanvas.scss +++ /dev/null @@ -1,306 +0,0 @@ -// Name: Off-canvas -// Description: Component to create an off-canvas sidebar -// -// Component: `uk-offcanvas` -// -// Sub-objects: `uk-offcanvas-bar` -// `uk-offcanvas-container` -// `uk-offcanvas-page` -// -// Adopted: `uk-offcanvas-close` -// -// Modifiers: `uk-offcanvas-flip` -// `uk-offcanvas-bar-animation` -// `uk-offcanvas-reveal` -// `uk-offcanvas-overlay` -// `uk-offcanvas-container-animation` -// -// States: `uk-open` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$offcanvas-z-index: $global-z-index !default; - -$offcanvas-bar-width: 270px !default; -$offcanvas-bar-padding-vertical: $global-margin !default; -$offcanvas-bar-padding-horizontal: $global-margin !default; -$offcanvas-bar-background: $global-secondary-background !default; -$offcanvas-bar-color-mode: light !default; - -$offcanvas-bar-width-m: 350px !default; -$offcanvas-bar-padding-vertical-m: $global-medium-gutter !default; -$offcanvas-bar-padding-horizontal-m: $global-medium-gutter !default; - -$offcanvas-close-position: 20px !default; -$offcanvas-close-padding: 5px !default; - -$offcanvas-overlay-background: rgba(0,0,0,0.1) !default; - - -/* ======================================================================== - Component: Off-canvas - ========================================================================== */ - -/* - * 1. Hide by default - * 2. Set position - */ - -.uk-offcanvas { - /* 1 */ - display: none; - /* 2 */ - position: fixed; - top: 0; - bottom: 0; - left: 0; - z-index: $offcanvas-z-index; -} - -/* - * Flip modifier - */ - -.uk-offcanvas-flip .uk-offcanvas { - right: 0; - left: auto; -} - - -/* Bar - ========================================================================== */ - -/* - * 1. Set position - * 2. Size and style - * 3. Allow scrolling - */ - -.uk-offcanvas-bar { - /* 1 */ - position: absolute; - top: 0; - bottom: 0; - left: (-$offcanvas-bar-width); - /* 2 */ - box-sizing: border-box; - width: $offcanvas-bar-width; - padding: $offcanvas-bar-padding-vertical $offcanvas-bar-padding-horizontal; - background: $offcanvas-bar-background; - /* 3 */ - overflow-y: auto; - -webkit-overflow-scrolling: touch; - @if(mixin-exists(hook-offcanvas-bar)) {@include hook-offcanvas-bar();} -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-offcanvas-bar { - left: (-$offcanvas-bar-width-m); - width: $offcanvas-bar-width-m; - padding: $offcanvas-bar-padding-vertical-m $offcanvas-bar-padding-horizontal-m; - } - -} - -// Color Mode -@if ( $offcanvas-bar-color-mode == light ) { .uk-offcanvas-bar { @extend .uk-light !optional;} } -@if ( $offcanvas-bar-color-mode == dark ) { .uk-offcanvas-bar { @extend .uk-dark !optional;} } - -/* Flip modifier */ -.uk-offcanvas-flip .uk-offcanvas-bar { - left: auto; - right: (-$offcanvas-bar-width); -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-offcanvas-flip .uk-offcanvas-bar { right: (-$offcanvas-bar-width-m); } - -} - -/* - * Open - */ - -.uk-open > .uk-offcanvas-bar { left: 0; } -.uk-offcanvas-flip .uk-open > .uk-offcanvas-bar { - left: auto; - right: 0; -} - -/* - * Slide Animation (Used in slide and push mode) - */ - -.uk-offcanvas-bar-animation { transition: left 0.3s ease-out; } -.uk-offcanvas-flip .uk-offcanvas-bar-animation { transition-property: right; } - -/* - * Reveal Animation - * 1. Set position - * 2. Clip the bar - * 3. Animation - * 4. Reset position - */ - -.uk-offcanvas-reveal { - /* 1 */ - position: absolute; - top: 0; - bottom: 0; - left: 0; - /* 2 */ - width: 0; - overflow: hidden; - /* 3 */ - transition: width 0.3s ease-out; -} - -.uk-offcanvas-reveal .uk-offcanvas-bar { - /* 4 */ - left: 0; -} - -.uk-offcanvas-flip .uk-offcanvas-reveal .uk-offcanvas-bar { - /* 4 */ - left: auto; - right: 0; -} - -.uk-open > .uk-offcanvas-reveal { width: $offcanvas-bar-width; } - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-open > .uk-offcanvas-reveal { width: $offcanvas-bar-width-m; } - -} - -/* - * Flip modifier - */ - -.uk-offcanvas-flip .uk-offcanvas-reveal { - right: 0; - left: auto; -} - - -/* Close - * Adopts `uk-close` - ========================================================================== */ - -.uk-offcanvas-close { - position: absolute; - z-index: $offcanvas-z-index; - top: $offcanvas-close-position; - right: $offcanvas-close-position; - padding: $offcanvas-close-padding; - @if(mixin-exists(hook-offcanvas-close)) {@include hook-offcanvas-close();} -} - - -/* Overlay - ========================================================================== */ - -/* - * Overlay the whole page. Needed for the `::before` - * 1. Using `100vw` so no modification is needed when off-canvas is flipped - * 2. Allow for closing with swipe gesture on devices with pointer events. - */ - -.uk-offcanvas-overlay { - /* 1 */ - width: 100vw; - /* 2 */ - touch-action: none; -} - -/* - * 1. Mask the whole page - * 2. Fade-in transition - */ - -.uk-offcanvas-overlay::before { - /* 1 */ - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - background: $offcanvas-overlay-background; - /* 2 */ - opacity: 0; - transition: opacity 0.15s linear; - @if(mixin-exists(hook-offcanvas-overlay)) {@include hook-offcanvas-overlay();} -} - -.uk-offcanvas-overlay.uk-open::before { opacity: 1; } - - -/* Prevent scrolling - ========================================================================== */ - -/* - * Prevent horizontal scrollbar when the content is slide-out - * Has to be on the `html` element too to make it work on the `body` - */ - -.uk-offcanvas-page, -.uk-offcanvas-container { overflow-x: hidden; } - - -/* Container - ========================================================================== */ - -/* - * Prepare slide-out animation (Used in reveal and push mode) - * Using `position: left` instead of `transform` because position `fixed` elements like sticky navbars - * lose their fixed state and behaves like `absolute` within a transformed container - * 1. Provide a fixed width and prevent shrinking - */ - -.uk-offcanvas-container { - position: relative; - left: 0; - transition: left 0.3s ease-out; - /* 1 */ - box-sizing: border-box; - width: 100%; -} - -/* - * Activate slide-out animation - */ - -:not(.uk-offcanvas-flip).uk-offcanvas-container-animation { left: $offcanvas-bar-width; } - -.uk-offcanvas-flip.uk-offcanvas-container-animation { left: (-$offcanvas-bar-width); } - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - :not(.uk-offcanvas-flip).uk-offcanvas-container-animation { left: $offcanvas-bar-width-m; } - - .uk-offcanvas-flip.uk-offcanvas-container-animation { left: (-$offcanvas-bar-width-m); } - -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-offcanvas-misc)) {@include hook-offcanvas-misc();} - -// @mixin hook-offcanvas-bar(){} -// @mixin hook-offcanvas-close(){} -// @mixin hook-offcanvas-overlay(){} -// @mixin hook-offcanvas-misc(){} diff --git a/docs/_sass/uikit/components/overlay.scss b/docs/_sass/uikit/components/overlay.scss deleted file mode 100644 index c3eb0a5711..0000000000 --- a/docs/_sass/uikit/components/overlay.scss +++ /dev/null @@ -1,85 +0,0 @@ -// Name: Overlay -// Description: Component to create content areas overlaying an image -// -// Component: `uk-overlay` -// -// Adopted: `uk-overlay-icon` -// -// Modifier: `uk-overlay-default` -// `uk-overlay-primary` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$overlay-padding-horizontal: $global-gutter !default; -$overlay-padding-vertical: $global-gutter !default; - -$overlay-default-background: rgba($global-background, 0.8) !default; - -$overlay-primary-background: rgba($global-secondary-background, 0.8) !default; -$overlay-primary-color-mode: light !default; - - -/* ======================================================================== - Component: Overlay - ========================================================================== */ - -.uk-overlay { - padding: $overlay-padding-vertical $overlay-padding-horizontal; - @if(mixin-exists(hook-overlay)) {@include hook-overlay();} -} - -/* - * Remove margin from the last-child - */ - -.uk-overlay > :last-child { margin-bottom: 0; } - - -/* Icon - ========================================================================== */ - -.uk-overlay-icon { - @if(mixin-exists(hook-overlay-icon)) {@include hook-overlay-icon();} -} - - -/* Style modifiers - ========================================================================== */ - -/* - * Default - */ - -.uk-overlay-default { - background: $overlay-default-background; - @if(mixin-exists(hook-overlay-default)) {@include hook-overlay-default();} -} - -/* - * Primary - */ - -.uk-overlay-primary { - background: $overlay-primary-background; - @if(mixin-exists(hook-overlay-primary)) {@include hook-overlay-primary();} -} - -// Color Mode -@if ( $overlay-primary-color-mode == light ) { .uk-overlay-primary { @extend .uk-light !optional;} } -@if ( $overlay-primary-color-mode == dark ) { .uk-overlay-primary { @extend .uk-dark !optional;} } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-overlay-misc)) {@include hook-overlay-misc();} - -// @mixin hook-overlay(){} -// @mixin hook-overlay-icon(){} -// @mixin hook-overlay-default(){} -// @mixin hook-overlay-primary(){} -// @mixin hook-overlay-misc(){} diff --git a/docs/_sass/uikit/components/padding.scss b/docs/_sass/uikit/components/padding.scss deleted file mode 100644 index 0c0f1ed10b..0000000000 --- a/docs/_sass/uikit/components/padding.scss +++ /dev/null @@ -1,81 +0,0 @@ -// Name: Padding -// Description: Utilities for padding -// -// Component: `uk-padding` -// `uk-padding-large` -// `uk-padding-remove-*` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$padding-padding: $global-gutter !default; -$padding-padding-l: $global-medium-gutter !default; - -$padding-small-padding: $global-small-gutter !default; - -$padding-large-padding: $global-gutter !default; -$padding-large-padding-l: $global-large-gutter !default; - - -/* ======================================================================== - Component: Padding - ========================================================================== */ - -.uk-padding { padding: $padding-padding; } - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-padding { padding: $padding-padding-l; } - -} - - -/* Small - ========================================================================== */ - -.uk-padding-small { padding: $padding-small-padding; } - - -/* Large - ========================================================================== */ - -.uk-padding-large { padding: $padding-large-padding; } - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-padding-large { padding: $padding-large-padding-l; } - -} - - -/* Remove - ========================================================================== */ - -.uk-padding-remove { padding: 0 !important; } -.uk-padding-remove-top { padding-top: 0 !important; } -.uk-padding-remove-bottom { padding-bottom: 0 !important; } -.uk-padding-remove-left { padding-left: 0 !important; } -.uk-padding-remove-right { padding-right: 0 !important; } - -.uk-padding-remove-vertical { - padding-top: 0 !important; - padding-bottom: 0 !important; -} - -.uk-padding-remove-horizontal { - padding-left: 0 !important; - padding-right: 0 !important; -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-padding-misc)) {@include hook-padding-misc();} - -// @mixin hook-padding-misc(){} diff --git a/docs/_sass/uikit/components/pagination.scss b/docs/_sass/uikit/components/pagination.scss deleted file mode 100644 index 6c501e7bdf..0000000000 --- a/docs/_sass/uikit/components/pagination.scss +++ /dev/null @@ -1,131 +0,0 @@ -// Name: Pagination -// Description: Component to create a page navigation -// -// Component: `uk-pagination` -// -// Adopted: `uk-pagination-next` -// `uk-pagination-previous` -// -// States: `uk-active` -// `uk-disabled` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$pagination-margin-horizontal: 0 !default; - -$pagination-item-padding-vertical: 5px !default; -$pagination-item-padding-horizontal: 10px !default; -$pagination-item-color: $global-muted-color !default; -$pagination-item-hover-color: $global-color !default; -$pagination-item-hover-text-decoration: none !default; -$pagination-item-active-color: $global-color !default; -$pagination-item-disabled-color: $global-muted-color !default; - - -/* ======================================================================== - Component: Pagination - ========================================================================== */ - -/* - * 1. Allow items to wrap into the next line - * 2. Gutter - * 3. Reset list - */ - -.uk-pagination { - display: flex; - /* 1 */ - flex-wrap: wrap; - /* 2 */ - margin-left: (-$pagination-margin-horizontal); - /* 3 */ - padding: 0; - list-style: none; - @if(mixin-exists(hook-pagination)) {@include hook-pagination();} -} - -/* - * 1. Space is allocated solely based on content dimensions: 0 0 auto - * 2. Gutter - * 3. Create position context for dropdowns - */ - -.uk-pagination > * { - /* 1 */ - flex: none; - /* 2 */ - padding-left: $pagination-margin-horizontal; - /* 3 */ - position: relative; -} - - -/* Items - ========================================================================== */ - -/* - * 1. Prevent gap if child element is `inline-block`, e.g. an icon - * 2. Style - */ - -.uk-pagination > * > * { - /* 1 */ - display: block; - /* 2 */ - padding: $pagination-item-padding-vertical $pagination-item-padding-horizontal; - color: $pagination-item-color; - @if(mixin-exists(hook-pagination-item)) {@include hook-pagination-item();} -} - -/* Hover + Focus */ -.uk-pagination > * > :hover, -.uk-pagination > * > :focus { - color: $pagination-item-hover-color; - text-decoration: $pagination-item-hover-text-decoration; - @if(mixin-exists(hook-pagination-item-hover)) {@include hook-pagination-item-hover();} -} - -/* Active */ -.uk-pagination > .uk-active > * { - color: $pagination-item-active-color; - @if(mixin-exists(hook-pagination-item-active)) {@include hook-pagination-item-active();} -} - -/* Disabled */ -.uk-pagination > .uk-disabled > * { - color: $pagination-item-disabled-color; - @if(mixin-exists(hook-pagination-item-disabled)) {@include hook-pagination-item-disabled();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-pagination-misc)) {@include hook-pagination-misc();} - -// @mixin hook-pagination(){} -// @mixin hook-pagination-item(){} -// @mixin hook-pagination-item-hover(){} -// @mixin hook-pagination-item-active(){} -// @mixin hook-pagination-item-disabled(){} -// @mixin hook-pagination-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-pagination-item-color: $inverse-global-muted-color !default; -$inverse-pagination-item-hover-color: $inverse-global-color !default; -$inverse-pagination-item-active-color: $inverse-global-color !default; -$inverse-pagination-item-disabled-color: $inverse-global-muted-color !default; - - - -// @mixin hook-inverse-pagination-item(){} -// @mixin hook-inverse-pagination-item-hover(){} -// @mixin hook-inverse-pagination-item-active(){} -// @mixin hook-inverse-pagination-item-disabled(){} diff --git a/docs/_sass/uikit/components/placeholder.scss b/docs/_sass/uikit/components/placeholder.scss deleted file mode 100644 index 05c06f7d92..0000000000 --- a/docs/_sass/uikit/components/placeholder.scss +++ /dev/null @@ -1,45 +0,0 @@ -// Name: Placeholder -// Description: Component to create placeholder boxes -// -// Component: `uk-placeholder` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$placeholder-margin-vertical: $global-margin !default; -$placeholder-padding-vertical: $global-gutter !default; -$placeholder-padding-horizontal: $global-gutter !default; -$placeholder-background: $global-muted-background !default; - - -/* ======================================================================== - Component: Placeholder - ========================================================================== */ - -.uk-placeholder { - margin-bottom: $placeholder-margin-vertical; - padding: $placeholder-padding-vertical $placeholder-padding-horizontal; - background: $placeholder-background; - @if(mixin-exists(hook-placeholder)) {@include hook-placeholder();} -} - -/* Add margin if adjacent element */ -* + .uk-placeholder { margin-top: $placeholder-margin-vertical; } - -/* - * Remove margin from the last-child - */ - -.uk-placeholder > :last-child { margin-bottom: 0; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-placeholder-misc)) {@include hook-placeholder-misc();} - -// @mixin hook-placeholder(){} -// @mixin hook-placeholder-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/position.scss b/docs/_sass/uikit/components/position.scss deleted file mode 100644 index 419e9270f4..0000000000 --- a/docs/_sass/uikit/components/position.scss +++ /dev/null @@ -1,265 +0,0 @@ -// Name: Position -// Description: Utilities to position content -// -// Component: `uk-position-absolute` -// `uk-position-relative` -// `uk-position-z-index` -// `uk-position-top` -// `uk-position-bottom` -// `uk-position-left` -// `uk-position-right` -// `uk-position-top-left` -// `uk-position-top-center` -// `uk-position-top-right` -// `uk-position-bottom-left` -// `uk-position-bottom-center` -// `uk-position-bottom-right` -// `uk-position-center` -// `uk-position-center-left` -// `uk-position-center-right` -// `uk-position-cover` -// -// Modifiers: `uk-position-small` -// `uk-position-medium` -// `uk-position-large` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$position-small-margin: $global-small-gutter !default; -$position-medium-margin: $global-gutter !default; -$position-large-margin: $global-gutter !default; -$position-large-margin-l: 50px !default; - - -/* ======================================================================== - Component: Position - ========================================================================== */ - - -/* Directions - ========================================================================== */ - -/* - * 1. Prevent content overflow if `max-width: 100%` is used inside position container. - */ - -[class*='uk-position-top'], -[class*='uk-position-bottom'], -[class*='uk-position-left'], -[class*='uk-position-right'], -[class*='uk-position-center'] { - position: absolute !important; - /* 1 */ - max-width: 100%; -} - - -/* Edges - ========================================================================== */ - -/* Don't use `width: 100%` because it is wrong if the parent has padding. */ -.uk-position-top { - top: 0; - left: 0; - right: 0; -} - -.uk-position-bottom { - bottom: 0; - left: 0; - right: 0; -} - -.uk-position-left { - top: 0; - bottom: 0; - left: 0; -} - -.uk-position-right { - top: 0; - bottom: 0; - right: 0; -} - - -/* Corners - ========================================================================== */ - -.uk-position-top-left { - top: 0; - left: 0; -} - -.uk-position-top-right { - top: 0; - right: 0; -} - -.uk-position-bottom-left { - bottom: 0; - left: 0; -} - -.uk-position-bottom-right { - bottom: 0; - right: 0; -} - -/* - * Center - * 1. Fix text wrapping if content is larger than 50% of the container. - */ - -.uk-position-center { - top: 50%; - left: 50%; - transform: translate(-50%,-50%); - /* 1 */ - width: max-content; - max-width: 100%; - box-sizing: border-box; -} - -/* Vertical */ -[class*='uk-position-center-left'], -[class*='uk-position-center-right'] { - top: 50%; - transform: translateY(-50%); -} - -.uk-position-center-left { left: 0; } -.uk-position-center-right { right: 0; } - -.uk-position-center-left-out { - right: 100%; - width: max-content; -} - -.uk-position-center-right-out { - left: 100%; - width: max-content; -} - -/* Horizontal */ -.uk-position-top-center, -.uk-position-bottom-center { - left: 50%; - transform: translateX(-50%); - /* 1 */ - width: max-content; - max-width: 100%; - box-sizing: border-box; -} - -.uk-position-top-center { top: 0; } -.uk-position-bottom-center { bottom: 0; } - - -/* Cover - ========================================================================== */ - -.uk-position-cover { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; -} - - -/* Utility - ========================================================================== */ - -.uk-position-relative { position: relative !important; } - -.uk-position-absolute { position: absolute !important; } - -.uk-position-fixed { position: fixed !important; } - -.uk-position-z-index { z-index: 1; } - - -/* Margin modifier - ========================================================================== */ - -/* - * Small - */ - -.uk-position-small { - max-width: unquote('calc(100% - (#{$position-small-margin} * 2))'); - margin: $position-small-margin; -} - -.uk-position-small.uk-position-center { transform: translate(-50%, -50%) translate(-$position-small-margin, (-$position-small-margin)); } - -.uk-position-small[class*='uk-position-center-left'], -.uk-position-small[class*='uk-position-center-right'] { transform: translateY(-50%) translateY(-$position-small-margin); } - -.uk-position-small.uk-position-top-center, -.uk-position-small.uk-position-bottom-center { transform: translateX(-50%) translateX(-$position-small-margin); } - -/* - * Medium - */ - -.uk-position-medium { - max-width: unquote('calc(100% - (#{$position-medium-margin} * 2))'); - margin: $position-medium-margin; -} - -.uk-position-medium.uk-position-center { transform: translate(-50%, -50%) translate(-$position-medium-margin, (-$position-medium-margin)); } - -.uk-position-medium[class*='uk-position-center-left'], -.uk-position-medium[class*='uk-position-center-right'] { transform: translateY(-50%) translateY(-$position-medium-margin); } - -.uk-position-medium.uk-position-top-center, -.uk-position-medium.uk-position-bottom-center { transform: translateX(-50%) translateX(-$position-medium-margin); } - -/* - * Large - */ - -.uk-position-large { - max-width: unquote('calc(100% - (#{$position-large-margin} * 2))'); - margin: $position-large-margin; -} - -.uk-position-large.uk-position-center { transform: translate(-50%, -50%) translate(-$position-large-margin, (-$position-large-margin)); } - -.uk-position-large[class*='uk-position-center-left'], -.uk-position-large[class*='uk-position-center-right'] { transform: translateY(-50%) translateY(-$position-large-margin); } - -.uk-position-large.uk-position-top-center, -.uk-position-large.uk-position-bottom-center { transform: translateX(-50%) translateX(-$position-large-margin); } - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-position-large { - max-width: unquote('calc(100% - (#{$position-large-margin-l} * 2))'); - margin: $position-large-margin-l; - } - - .uk-position-large.uk-position-center { transform: translate(-50%, -50%) translate(-$position-large-margin-l, (-$position-large-margin-l)); } - - .uk-position-large[class*='uk-position-center-left'], - .uk-position-large[class*='uk-position-center-right'] { transform: translateY(-50%) translateY(-$position-large-margin-l); } - - .uk-position-large.uk-position-top-center, - .uk-position-large.uk-position-bottom-center { transform: translateX(-50%) translateX(-$position-large-margin-l); } - -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-position-misc)) {@include hook-position-misc();} - -// @mixin hook-position-misc(){} diff --git a/docs/_sass/uikit/components/print.scss b/docs/_sass/uikit/components/print.scss deleted file mode 100644 index 6162df525f..0000000000 --- a/docs/_sass/uikit/components/print.scss +++ /dev/null @@ -1,61 +0,0 @@ -// Name: Print -// Description: Optimize page for printing -// -// Adapted from http://github.com/h5bp/html5-boilerplate -// -// Modifications: Removed link `href` and `title` related rules -// -// ======================================================================== - - -/* ======================================================================== - Component: Print - ========================================================================== */ - -@media print { - - *, - *::before, - *::after { - background: transparent !important; - color: black !important; - box-shadow: none !important; - text-shadow: none !important; - } - - a, - a:visited { text-decoration: underline; } - - pre, - blockquote { - border: 1px solid #999; - page-break-inside: avoid; - } - - thead { display: table-header-group; } - - tr, - img { page-break-inside: avoid; } - - img { max-width: 100% !important; } - - @page { margin: 0.5cm; } - - p, - h2, - h3 { - orphans: 3; - widows: 3; - } - - h2, - h3 { page-break-after: avoid; } - - @if(mixin-exists(hook-print)) {@include hook-print();} - -} - -// Hooks -// ======================================================================== - -// @mixin hook-print(){} diff --git a/docs/_sass/uikit/components/progress.scss b/docs/_sass/uikit/components/progress.scss deleted file mode 100644 index 4575513ed6..0000000000 --- a/docs/_sass/uikit/components/progress.scss +++ /dev/null @@ -1,105 +0,0 @@ -// Name: Progress -// Description: Component to create progress bars -// -// Component: `uk-progress` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$progress-height: 15px !default; -$progress-margin-vertical: $global-margin !default; -$progress-background: $global-muted-background !default; - -$progress-bar-background: $global-primary-background !default; - - -/* ======================================================================== - Component: Progress - ========================================================================== */ - -/* - * 1. Add the correct vertical alignment in Chrome, Firefox, and Opera. - * 2. Remove default style - * 3. Behave like a block element - * 4. Remove borders in Firefox and Edge - * 5. Set background color for progress container in Firefox, IE11 and Edge - * 6. Style - */ - -.uk-progress { - /* 1 */ - vertical-align: baseline; - /* 2 */ - -webkit-appearance: none; - -moz-appearance: none; - /* 3 */ - display: block; - width: 100%; - /* 4 */ - border: 0; - /* 5 */ - background-color: $progress-background; - /* 6 */ - margin-bottom: $progress-margin-vertical; - height: $progress-height; - @if(mixin-exists(hook-progress)) {@include hook-progress();} -} - -/* Add margin if adjacent element */ -* + .uk-progress { margin-top: $progress-margin-vertical; } - -/* - * Remove animated circles for indeterminate state in IE11 and Edge - */ - -.uk-progress:indeterminate { color: transparent; } - -/* - * Progress container - * 2. Remove progress bar for indeterminate state in Firefox - */ - -.uk-progress::-webkit-progress-bar { - background-color: $progress-background; - @if(mixin-exists(hook-progress)) {@include hook-progress();} -} - -/* 2 */ -.uk-progress:indeterminate::-moz-progress-bar { width: 0; } - -/* - * Progress bar - * 1. Remove right border in IE11 and Edge - */ - -.uk-progress::-webkit-progress-value { - background-color: $progress-bar-background; - transition: width 0.6s ease; - @if(mixin-exists(hook-progress-bar)) {@include hook-progress-bar();} -} - -.uk-progress::-moz-progress-bar { - background-color: $progress-bar-background; - @if(mixin-exists(hook-progress-bar)) {@include hook-progress-bar();} -} - -.uk-progress::-ms-fill { - background-color: $progress-bar-background; - transition: width 0.6s ease; - /* 1 */ - border: 0; - @if(mixin-exists(hook-progress-bar)) {@include hook-progress-bar();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-progress-misc)) {@include hook-progress-misc();} - -// @mixin hook-progress(){} -// @mixin hook-progress-bar(){} -// @mixin hook-progress-misc(){} diff --git a/docs/_sass/uikit/components/search.scss b/docs/_sass/uikit/components/search.scss deleted file mode 100644 index 9f5e0e090c..0000000000 --- a/docs/_sass/uikit/components/search.scss +++ /dev/null @@ -1,328 +0,0 @@ -// Name: Search -// Description: Component to create the search -// -// Component: `uk-search` -// -// Sub-objects: `uk-search-input` -// `uk-search-toggle` -// -// Adopted: `uk-search-icon` -// -// Modifier: `uk-search-default` -// `uk-search-navbar` -// `uk-search-large` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$search-color: $global-color !default; -$search-placeholder-color: $global-muted-color !default; - -$search-icon-color: $global-muted-color !default; - -$search-default-width: 240px !default; -$search-default-height: $global-control-height !default; -$search-default-padding-horizontal: 10px !default; -$search-default-background: $global-muted-background !default; -$search-default-focus-background: darken($search-default-background, 5%) !default; - -$search-default-icon-width: $global-control-height !default; - -$search-navbar-width: 400px !default; -$search-navbar-height: 40px !default; -$search-navbar-background: transparent !default; -$search-navbar-font-size: $global-large-font-size !default; - -$search-navbar-icon-width: 40px !default; - -$search-large-width: 500px !default; -$search-large-height: 80px !default; -$search-large-background: transparent !default; -$search-large-font-size: $global-2xlarge-font-size !default; - -$search-large-icon-width: 80px !default; - -$search-toggle-color: $global-muted-color !default; -$search-toggle-hover-color: $global-color !default; - - -/* ======================================================================== - Component: Search - ========================================================================== */ - -/* - * 1. Container fits its content - * 2. Create position context - * 3. Prevent content overflow - * 4. Reset `form` - */ - -.uk-search { - /* 1 */ - display: inline-block; - /* 2 */ - position: relative; - /* 3 */ - max-width: 100%; - /* 4 */ - margin: 0; -} - - -/* Input - ========================================================================== */ - -/* - * Remove the inner padding and cancel buttons in Chrome on OS X and Safari on OS X. - */ - -.uk-search-input::-webkit-search-cancel-button, -.uk-search-input::-webkit-search-decoration { -webkit-appearance: none; } - -/* - * Removes placeholder transparency in Firefox. - */ - -.uk-search-input::-moz-placeholder { opacity: 1; } - -/* - * 1. Define consistent box sizing. - * 2. Address margins set differently in Firefox/IE and Chrome/Safari/Opera. - * 3. Remove `border-radius` in iOS. - * 4. Change font properties to `inherit` in all browsers - * 5. Show the overflow in Edge. - * 6. Remove default style in iOS. - * 7. Vertical alignment - * 8. Take the full container width - * 9. Style - */ - -.uk-search-input { - /* 1 */ - box-sizing: border-box; - /* 2 */ - margin: 0; - /* 3 */ - border-radius: 0; - /* 4 */ - font: inherit; - /* 5 */ - overflow: visible; - /* 6 */ - -webkit-appearance: none; - /* 7 */ - vertical-align: middle; - /* 8 */ - width: 100%; - /* 9 */ - border: none; - color: $search-color; - @if(mixin-exists(hook-search-input)) {@include hook-search-input();} -} - -.uk-search-input:focus { outline: none; } - -/* Placeholder */ -.uk-search-input:-ms-input-placeholder { color: $search-placeholder-color !important; } -.uk-search-input::placeholder { color: $search-placeholder-color; } - - -/* Icon (Adopts `uk-icon`) - ========================================================================== */ - -/* - * Remove default focus style - */ - -.uk-search-icon:focus { outline: none; } - -/* - * Position above input - * 1. Set position - * 2. Center icon vertically and horizontally - * 3. Style - */ - -.uk-search .uk-search-icon { - /* 1 */ - position: absolute; - top: 0; - bottom: 0; - left: 0; - /* 2 */ - display: inline-flex; - justify-content: center; - align-items: center; - /* 3 */ - color: $search-icon-color; -} - -/* - * Required for `a`. - */ - -.uk-search .uk-search-icon:hover { color: $search-icon-color; } - -/* - * Make `input` element clickable through icon, e.g. if it's a `span` - */ - -.uk-search .uk-search-icon:not(a):not(button):not(input) { pointer-events: none; } - -/* - * Position modifier - */ - -.uk-search .uk-search-icon-flip { - right: 0; - left: auto; -} - - -/* Default modifier - ========================================================================== */ - -.uk-search-default { width: $search-default-width; } - -/* - * Input - */ - -.uk-search-default .uk-search-input { - height: $search-default-height; - padding-left: $search-default-padding-horizontal; - padding-right: $search-default-padding-horizontal; - background: $search-default-background; - @if(mixin-exists(hook-search-default-input)) {@include hook-search-default-input();} -} - -/* Focus */ -.uk-search-default .uk-search-input:focus { - background-color: $search-default-focus-background; - @if(mixin-exists(hook-search-default-input-focus)) {@include hook-search-default-input-focus();} -} - -/* - * Icon - */ - -.uk-search-default .uk-search-icon { width: $search-default-icon-width; } - -.uk-search-default .uk-search-icon:not(.uk-search-icon-flip) ~ .uk-search-input { padding-left: ($search-default-icon-width); } -.uk-search-default .uk-search-icon-flip ~ .uk-search-input { padding-right: ($search-default-icon-width); } - - -/* Navbar modifier - ========================================================================== */ - -.uk-search-navbar { width: $search-navbar-width; } - -/* - * Input - */ - -.uk-search-navbar .uk-search-input { - height: $search-navbar-height; - background: $search-navbar-background; - font-size: $search-navbar-font-size; - @if(mixin-exists(hook-search-navbar-input)) {@include hook-search-navbar-input();} -} - -/* - * Icon - */ - -.uk-search-navbar .uk-search-icon { width: $search-navbar-icon-width; } - -.uk-search-navbar .uk-search-icon:not(.uk-search-icon-flip) ~ .uk-search-input { padding-left: ($search-navbar-icon-width); } -.uk-search-navbar .uk-search-icon-flip ~ .uk-search-input { padding-right: ($search-navbar-icon-width); } - - -/* Large modifier - ========================================================================== */ - -.uk-search-large { width: $search-large-width; } - -/* - * Input - */ - -.uk-search-large .uk-search-input { - height: $search-large-height; - background: $search-large-background; - font-size: $search-large-font-size; - @if(mixin-exists(hook-search-large-input)) {@include hook-search-large-input();} -} - -/* - * Icon - */ - -.uk-search-large .uk-search-icon { width: $search-large-icon-width; } - -.uk-search-large .uk-search-icon:not(.uk-search-icon-flip) ~ .uk-search-input { padding-left: ($search-large-icon-width); } -.uk-search-large .uk-search-icon-flip ~ .uk-search-input { padding-right: ($search-large-icon-width); } - - -/* Toggle - ========================================================================== */ - -.uk-search-toggle { - color: $search-toggle-color; - @if(mixin-exists(hook-search-toggle)) {@include hook-search-toggle();} -} - -/* Hover + Focus */ -.uk-search-toggle:hover, -.uk-search-toggle:focus { - color: $search-toggle-hover-color; - @if(mixin-exists(hook-search-toggle-hover)) {@include hook-search-toggle-hover();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-search-misc)) {@include hook-search-misc();} - -// @mixin hook-search-input(){} -// @mixin hook-search-default-input(){} -// @mixin hook-search-default-input-focus(){} -// @mixin hook-search-navbar-input(){} -// @mixin hook-search-large-input(){} - -// @mixin hook-search-toggle(){} -// @mixin hook-search-toggle-hover(){} - -// @mixin hook-search-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-search-color: $inverse-global-color !default; -$inverse-search-placeholder-color: $inverse-global-muted-color !default; - -$inverse-search-icon-color: $inverse-global-muted-color !default; - -$inverse-search-default-background: $inverse-global-muted-background !default; -$inverse-search-default-focus-background: fadein($inverse-search-default-background, 5%) !default; - -$inverse-search-navbar-background: transparent !default; - -$inverse-search-large-background: transparent !default; - -$inverse-search-toggle-color: $inverse-global-muted-color !default; -$inverse-search-toggle-hover-color: $inverse-global-color !default; - - - -// @mixin hook-inverse-search-default-input(){} -// @mixin hook-inverse-search-default-input-focus(){} -// @mixin hook-inverse-search-navbar-input(){} -// @mixin hook-inverse-search-large-input(){} -// @mixin hook-inverse-search-toggle(){} -// @mixin hook-inverse-search-toggle-hover(){} diff --git a/docs/_sass/uikit/components/section.scss b/docs/_sass/uikit/components/section.scss deleted file mode 100644 index a3eacb1d06..0000000000 --- a/docs/_sass/uikit/components/section.scss +++ /dev/null @@ -1,212 +0,0 @@ -// Name: Section -// Description: Component to create horizontal layout section -// -// Component: `uk-section` -// -// Modifiers: `uk-section-xsmall` -// `uk-section-small` -// `uk-section-large` -// `uk-section-xlarge` -// `uk-section-default` -// `uk-section-muted` -// `uk-section-primary` -// `uk-section-secondary` -// `uk-section-overlap` -// -// States: `uk-preserve-color` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$section-padding-vertical: $global-medium-margin !default; -$section-padding-vertical-m: $global-large-margin !default; - -$section-xsmall-padding-vertical: $global-margin !default; - -$section-small-padding-vertical: $global-medium-margin !default; - -$section-large-padding-vertical: $global-large-margin !default; -$section-large-padding-vertical-m: $global-xlarge-margin !default; - -$section-xlarge-padding-vertical: $global-xlarge-margin !default; -$section-xlarge-padding-vertical-m: ($global-large-margin + $global-xlarge-margin) !default; - -$section-default-background: $global-background !default; - -$section-muted-background: $global-muted-background !default; - -$section-primary-background: $global-primary-background !default; -$section-primary-color-mode: light !default; - -$section-secondary-background: $global-secondary-background !default; -$section-secondary-color-mode: light !default; - - -/* ======================================================================== - Component: Section - ========================================================================== */ - -/* - * 1. Make it work with `100vh` and height in general - */ - -.uk-section { - display: flow-root; - box-sizing: border-box; /* 1 */ - padding-top: $section-padding-vertical; - padding-bottom: $section-padding-vertical; - @if(mixin-exists(hook-section)) {@include hook-section();} -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-section { - padding-top: $section-padding-vertical-m; - padding-bottom: $section-padding-vertical-m; - } - -} - -/* - * Remove margin from the last-child - */ - -.uk-section > :last-child { margin-bottom: 0; } - - -/* Size modifiers - ========================================================================== */ - -/* - * XSmall - */ - -.uk-section-xsmall { - padding-top: $section-xsmall-padding-vertical; - padding-bottom: $section-xsmall-padding-vertical; -} - -/* - * Small - */ - -.uk-section-small { - padding-top: $section-small-padding-vertical; - padding-bottom: $section-small-padding-vertical; -} - -/* - * Large - */ - -.uk-section-large { - padding-top: $section-large-padding-vertical; - padding-bottom: $section-large-padding-vertical; -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-section-large { - padding-top: $section-large-padding-vertical-m; - padding-bottom: $section-large-padding-vertical-m; - } - -} - - -/* - * XLarge - */ - -.uk-section-xlarge { - padding-top: $section-xlarge-padding-vertical; - padding-bottom: $section-xlarge-padding-vertical; -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-section-xlarge { - padding-top: $section-xlarge-padding-vertical-m; - padding-bottom: $section-xlarge-padding-vertical-m; - } - -} - - -/* Style modifiers - ========================================================================== */ - -/* - * Default - */ - -.uk-section-default { - background: $section-default-background; - @if(mixin-exists(hook-section-default)) {@include hook-section-default();} -} - -/* - * Muted - */ - -.uk-section-muted { - background: $section-muted-background; - @if(mixin-exists(hook-section-muted)) {@include hook-section-muted();} -} - -/* - * Primary - */ - -.uk-section-primary { - background: $section-primary-background; - @if(mixin-exists(hook-section-primary)) {@include hook-section-primary();} -} - -@if ( $section-primary-color-mode == light ) { .uk-section-primary:not(.uk-preserve-color) { @extend .uk-light !optional;} } -@if ( $section-primary-color-mode == dark ) { .uk-section-primary:not(.uk-preserve-color) { @extend .uk-dark !optional;} } - -/* - * Secondary - */ - -.uk-section-secondary { - background: $section-secondary-background; - @if(mixin-exists(hook-section-secondary)) {@include hook-section-secondary();} -} - -@if ( $section-secondary-color-mode == light ) { .uk-section-secondary:not(.uk-preserve-color) { @extend .uk-light !optional;} } -@if ( $section-secondary-color-mode == dark ) { .uk-section-secondary:not(.uk-preserve-color) { @extend .uk-dark !optional;} } - - -/* Overlap modifier - ========================================================================== */ - -/* - * Reserved modifier to make a section overlap another section with an border image - * Implemented by the theme - */ - -.uk-section-overlap { - @if(mixin-exists(hook-section-overlap)) {@include hook-section-overlap();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-section-misc)) {@include hook-section-misc();} - -// @mixin hook-section(){} -// @mixin hook-section-default(){} -// @mixin hook-section-muted(){} -// @mixin hook-section-secondary(){} -// @mixin hook-section-primary(){} -// @mixin hook-section-overlap(){} -// @mixin hook-section-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/slidenav.scss b/docs/_sass/uikit/components/slidenav.scss deleted file mode 100644 index 0b9af8f256..0000000000 --- a/docs/_sass/uikit/components/slidenav.scss +++ /dev/null @@ -1,122 +0,0 @@ -// Name: Slidenav -// Description: Component to create previous/next icon navigations -// -// Component: `uk-slidenav` -// -// Sub-objects: `uk-slidenav-container` -// -// Modifiers: `uk-slidenav-previous` -// `uk-slidenav-next` -// `uk-slidenav-large` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$slidenav-padding-vertical: 5px !default; -$slidenav-padding-horizontal: 10px !default; - -$slidenav-color: rgba($global-color, 0.5) !default; -$slidenav-hover-color: rgba($global-color, 0.9) !default; -$slidenav-active-color: rgba($global-color, 0.5) !default; - -$slidenav-large-padding-vertical: 10px !default; -$slidenav-large-padding-horizontal: $slidenav-large-padding-vertical !default; - - -/* ======================================================================== - Component: Slidenav - ========================================================================== */ - -/* - * Adopts `uk-icon` - */ - -.uk-slidenav { - padding: $slidenav-padding-vertical $slidenav-padding-horizontal; - color: $slidenav-color; - @if(mixin-exists(hook-slidenav)) {@include hook-slidenav();} -} - -/* Hover + Focus */ -.uk-slidenav:hover, -.uk-slidenav:focus { - color: $slidenav-hover-color; - outline: none; - @if(mixin-exists(hook-slidenav-hover)) {@include hook-slidenav-hover();} -} - -/* OnClick */ -.uk-slidenav:active { - color: $slidenav-active-color; - @if(mixin-exists(hook-slidenav-active)) {@include hook-slidenav-active();} -} - - -/* Icon modifier - ========================================================================== */ - -/* - * Previous - */ - -.uk-slidenav-previous { - @if(mixin-exists(hook-slidenav-previous)) {@include hook-slidenav-previous();} -} - -/* - * Next - */ - -.uk-slidenav-next { - @if(mixin-exists(hook-slidenav-next)) {@include hook-slidenav-next();} -} - - -/* Size modifier - ========================================================================== */ - -.uk-slidenav-large { - padding: $slidenav-large-padding-vertical $slidenav-large-padding-horizontal; - @if(mixin-exists(hook-slidenav-large)) {@include hook-slidenav-large();} -} - - -/* Container - ========================================================================== */ - -.uk-slidenav-container { - display: flex; - @if(mixin-exists(hook-slidenav-container)) {@include hook-slidenav-container();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-slidenav-misc)) {@include hook-slidenav-misc();} - -// @mixin hook-slidenav(){} -// @mixin hook-slidenav-hover(){} -// @mixin hook-slidenav-active(){} -// @mixin hook-slidenav-previous(){} -// @mixin hook-slidenav-next(){} -// @mixin hook-slidenav-large(){} -// @mixin hook-slidenav-container(){} -// @mixin hook-slidenav-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-slidenav-color: rgba($inverse-global-color, 0.7) !default; -$inverse-slidenav-hover-color: rgba($inverse-global-color, 0.95) !default; -$inverse-slidenav-active-color: rgba($inverse-global-color, 0.7) !default; - - - -// @mixin hook-inverse-slidenav(){} -// @mixin hook-inverse-slidenav-hover(){} -// @mixin hook-inverse-slidenav-active(){} diff --git a/docs/_sass/uikit/components/slider.scss b/docs/_sass/uikit/components/slider.scss deleted file mode 100644 index d73ec1f8da..0000000000 --- a/docs/_sass/uikit/components/slider.scss +++ /dev/null @@ -1,120 +0,0 @@ -// Name: Slider -// Description: Component to create horizontal sliders -// -// Component: `uk-slider` -// -// Sub-objects: `uk-slider-container` -// `uk-slider-items` -// -// States: `uk-active` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$slider-container-margin-top: -11px !default; -$slider-container-margin-bottom: -39px !default; -$slider-container-margin-left: -25px !default; -$slider-container-margin-right: -25px !default; - - -/* ======================================================================== - Component: Slider - ========================================================================== */ - -/* - * 1. Prevent tab highlighting on iOS. - */ - -.uk-slider { - /* 1 */ - -webkit-tap-highlight-color: transparent; - @if(mixin-exists(hook-slider)) {@include hook-slider();} -} - - -/* Container - ========================================================================== */ - -/* - * Clip child elements - */ - -.uk-slider-container { overflow: hidden; } - -/* - * Widen container to prevent box-shadows from clipping, `large-box-shadow` - */ - -.uk-slider-container-offset { - margin: $slider-container-margin-top $slider-container-margin-right $slider-container-margin-bottom $slider-container-margin-left; - padding: ($slider-container-margin-top * -1) ($slider-container-margin-right * -1) ($slider-container-margin-bottom * -1) ($slider-container-margin-left * -1); -} - -/* Items - ========================================================================== */ - -/* - * 1. Optimize animation - * 2. Create a containing block. In Safari it's neither created by `transform` nor `will-change`. - */ - -.uk-slider-items { - /* 1 */ - will-change: transform; - /* 2 */ - position: relative; -} - -/* - * 1. Reset list style without interfering with grid - * 2. Prevent displaying the callout information on iOS. - */ - -.uk-slider-items:not(.uk-grid) { - display: flex; - /* 1 */ - margin: 0; - padding: 0; - list-style: none; - /* 2 */ - -webkit-touch-callout: none; -} - -.uk-slider-items.uk-grid { flex-wrap: nowrap; } - - -/* Item - ========================================================================== */ - -/* - * 1. Let items take content dimensions (0 0 auto) - * `max-width` needed to keep image responsiveness and prevent content overflow - * 3. Create position context - * 4. Disable horizontal panning gestures in IE11 and Edge - * 5. Suppress outline on focus - */ - -.uk-slider-items > * { - /* 1 */ - flex: none; - max-width: 100%; - /* 3 */ - position: relative; - /* 4 */ - touch-action: pan-y; -} - -/* 5 */ -.uk-slider-items > :focus { outline: none; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-slider-misc)) {@include hook-slider-misc();} - -// @mixin hook-slider(){} -// @mixin hook-slider-misc(){} diff --git a/docs/_sass/uikit/components/slideshow.scss b/docs/_sass/uikit/components/slideshow.scss deleted file mode 100644 index 8a8117eb98..0000000000 --- a/docs/_sass/uikit/components/slideshow.scss +++ /dev/null @@ -1,97 +0,0 @@ -// Name: Slideshow -// Description: Component to create slideshows -// -// Component: `uk-slideshow` -// -// Sub-objects: `uk-slideshow-items` -// -// States: `uk-active` -// -// ======================================================================== - - -/* ======================================================================== - Component: Slideshow - ========================================================================== */ - -/* - * 1. Prevent tab highlighting on iOS. - */ - -.uk-slideshow { - /* 1 */ - -webkit-tap-highlight-color: transparent; - @if(mixin-exists(hook-slideshow)) {@include hook-slideshow();} -} - - -/* Items - ========================================================================== */ - -/* - * 1. Create position and stacking context - * 2. Reset list - * 3. Clip child elements - * 4. Prevent displaying the callout information on iOS. - */ - -.uk-slideshow-items { - /* 1 */ - position: relative; - z-index: 0; - /* 2 */ - margin: 0; - padding: 0; - list-style: none; - /* 3 */ - overflow: hidden; - /* 4 */ - -webkit-touch-callout: none; -} - - -/* Item - ========================================================================== */ - -/* - * 1. Position items above each other - * 2. Take the full width - * 3. Clip child elements, e.g. for `uk-cover` - * 4. Optimize animation - * 5. Disable horizontal panning gestures in IE11 and Edge - * 6. Suppress outline on focus - */ - -.uk-slideshow-items > * { - /* 1 */ - position: absolute; - top: 0; - left: 0; - /* 2 */ - right: 0; - bottom: 0; - /* 3 */ - overflow: hidden; - /* 4 */ - will-change: transform, opacity; - /* 5 */ - touch-action: pan-y; -} - -/* 6 */ -.uk-slideshow-items > :focus { outline: none; } - -/* - * Hide not active items - */ - -.uk-slideshow-items > :not(.uk-active) { display: none; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-slideshow-misc)) {@include hook-slideshow-misc();} - -// @mixin hook-slideshow(){} -// @mixin hook-slideshow-misc(){} diff --git a/docs/_sass/uikit/components/sortable.scss b/docs/_sass/uikit/components/sortable.scss deleted file mode 100644 index 5bc66aeb02..0000000000 --- a/docs/_sass/uikit/components/sortable.scss +++ /dev/null @@ -1,90 +0,0 @@ -// Name: Sortable -// Description: Component to create sortable grids and lists -// -// Component: `uk-sortable` -// -// Sub-objects: `uk-sortable-drag` -// `uk-sortable-placeholder` -// `uk-sortable-handle` -// -// Modifiers: `uk-sortable-empty` -// -// States: `uk-drag` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$sortable-dragged-z-index: $global-z-index + 50 !default; - -$sortable-placeholder-opacity: 0 !default; - -$sortable-empty-height: 50px !default; - - -/* ======================================================================== - Component: Sortable - ========================================================================== */ - -.uk-sortable { - position: relative; - @if(mixin-exists(hook-sortable)) {@include hook-sortable();} -} - -/* - * Remove margin from the last-child - */ - -.uk-sortable > :last-child { margin-bottom: 0; } - - -/* Drag - ========================================================================== */ - -.uk-sortable-drag { - position: fixed !important; - z-index: $sortable-dragged-z-index !important; - pointer-events: none; - @if(mixin-exists(hook-sortable-drag)) {@include hook-sortable-drag();} -} - - -/* Placeholder - ========================================================================== */ - -.uk-sortable-placeholder { - opacity: $sortable-placeholder-opacity; - pointer-events: none; - @if(mixin-exists(hook-sortable-placeholder)) {@include hook-sortable-placeholder();} -} - - -/* Empty modifier - ========================================================================== */ - -.uk-sortable-empty { - min-height: $sortable-empty-height; - @if(mixin-exists(hook-sortable-empty)) {@include hook-sortable-empty();} -} - - -/* Handle - ========================================================================== */ - -/* Hover */ -.uk-sortable-handle:hover { cursor: move; } - - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-sortable-misc)) {@include hook-sortable-misc();} - -// @mixin hook-sortable(){} -// @mixin hook-sortable-drag(){} -// @mixin hook-sortable-placeholder(){} -// @mixin hook-sortable-empty(){} -// @mixin hook-sortable-misc(){} diff --git a/docs/_sass/uikit/components/spinner.scss b/docs/_sass/uikit/components/spinner.scss deleted file mode 100644 index a02f41d17b..0000000000 --- a/docs/_sass/uikit/components/spinner.scss +++ /dev/null @@ -1,74 +0,0 @@ -// Name: Spinner -// Description: Component to create a loading spinner -// -// Component: `uk-spinner` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$spinner-size: 30px !default; -$spinner-stroke-width: 1 !default; -$spinner-radius: floor(($spinner-size - $spinner-stroke-width) / 2) !default; // Minus stroke width to prevent overflow clipping -$spinner-circumference: round(2 * 3.141 * $spinner-radius) !default; -$spinner-duration: 1.4s !default; - - -/* ======================================================================== - Component: Spinner - ========================================================================== */ - -/* - * Adopts `uk-icon` - */ - -.uk-spinner { - @if(mixin-exists(hook-spinner)) {@include hook-spinner();} -} - - -/* SVG - ========================================================================== */ - -.uk-spinner > * { animation: uk-spinner-rotate $spinner-duration linear infinite; } - -@keyframes uk-spinner-rotate { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(270deg); } -} - -/* - * Circle - */ - -.uk-spinner > * > * { - stroke-dasharray: $spinner-circumference; - stroke-dashoffset: 0; - transform-origin: center; - animation: uk-spinner-dash $spinner-duration ease-in-out infinite; - stroke-width: $spinner-stroke-width; - stroke-linecap: round; -} - -@keyframes uk-spinner-dash { - 0% { stroke-dashoffset: $spinner-circumference; } - 50% { - stroke-dashoffset: $spinner-circumference/4; - transform:rotate(135deg); - } - 100% { - stroke-dashoffset: $spinner-circumference; - transform:rotate(450deg); - } -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-spinner-misc)) {@include hook-spinner-misc();} - -// @mixin hook-spinner(){} -// @mixin hook-spinner-misc(){} diff --git a/docs/_sass/uikit/components/sticky.scss b/docs/_sass/uikit/components/sticky.scss deleted file mode 100644 index fe976d1926..0000000000 --- a/docs/_sass/uikit/components/sticky.scss +++ /dev/null @@ -1,53 +0,0 @@ -// Name: Sticky -// Description: Component to make elements sticky in the viewport -// -// Component: `uk-sticky` -// -// Modifier: `uk-sticky-fixed` -// -// States: `uk-active` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$sticky-z-index: $global-z-index - 20 !default; - -$sticky-animation-duration: 0.2s !default; -$sticky-reverse-animation-duration: 0.2s !default; - - -/* ======================================================================== - Component: Sticky - ========================================================================== */ - -/* - * 1. Force new layer to resolve frame rate issues on devices with lower frame rates - */ - -.uk-sticky-fixed { - z-index: $sticky-z-index; - box-sizing: border-box; - margin: 0 !important; - /* 1 */ - -webkit-backface-visibility: hidden; - backface-visibility: hidden; -} - -/* - * Faster animations - */ - -.uk-sticky[class*='uk-animation-'] { animation-duration: $sticky-animation-duration; } - -.uk-sticky.uk-animation-reverse { animation-duration: $sticky-reverse-animation-duration; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-sticky-misc)) {@include hook-sticky-misc();} - -// @mixin hook-sticky-misc(){} diff --git a/docs/_sass/uikit/components/subnav.scss b/docs/_sass/uikit/components/subnav.scss deleted file mode 100644 index 8b0d4548ed..0000000000 --- a/docs/_sass/uikit/components/subnav.scss +++ /dev/null @@ -1,249 +0,0 @@ -// Name: Subnav -// Description: Component to create a sub navigation -// -// Component: `uk-subnav` -// -// Modifiers: `uk-subnav-divider` -// `uk-subnav-pill` -// -// States: `uk-active` -// `uk-first-column` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$subnav-margin-horizontal: 20px !default; - -$subnav-item-color: $global-muted-color !default; -$subnav-item-hover-color: $global-color !default; -$subnav-item-hover-text-decoration: none !default; -$subnav-item-active-color: $global-emphasis-color !default; - -$subnav-divider-margin-horizontal: $subnav-margin-horizontal !default; -$subnav-divider-border-height: 1.5em !default; -$subnav-divider-border-width: $global-border-width !default; -$subnav-divider-border: $global-border !default; - -$subnav-pill-item-padding-vertical: 5px !default; -$subnav-pill-item-padding-horizontal: 10px !default; -$subnav-pill-item-background: transparent !default; -$subnav-pill-item-color: $subnav-item-color !default; -$subnav-pill-item-hover-background: $global-muted-background !default; -$subnav-pill-item-hover-color: $global-color !default; -$subnav-pill-item-onclick-background: $subnav-pill-item-hover-background !default; -$subnav-pill-item-onclick-color: $subnav-pill-item-hover-color !default; -$subnav-pill-item-active-background: $global-primary-background !default; -$subnav-pill-item-active-color: $global-inverse-color !default; - -$subnav-item-disabled-color: $global-muted-color !default; - - -/* ======================================================================== - Component: Subnav - ========================================================================== */ - -/* - * 1. Allow items to wrap into the next line - * 2. Center items vertically if they have a different height - * 3. Gutter - * 4. Reset list - */ - -.uk-subnav { - display: flex; - /* 1 */ - flex-wrap: wrap; - /* 2 */ - align-items: center; - /* 3 */ - margin-left: (-$subnav-margin-horizontal); - /* 4 */ - padding: 0; - list-style: none; - @if(mixin-exists(hook-subnav)) {@include hook-subnav();} -} - -/* - * 1. Space is allocated solely based on content dimensions: 0 0 auto - * 2. Gutter - * 3. Create position context for dropdowns - */ - -.uk-subnav > * { - /* 1 */ - flex: none; - /* 2 */ - padding-left: $subnav-margin-horizontal; - /* 3 */ - position: relative; -} - - -/* Items - ========================================================================== */ - -/* - * Items must target `a` elements to exclude other elements (e.g. dropdowns) - * Using `:first-child` instead of `a` to support `span` elements for text - * 1. Center content vertically, e.g. an icon - * 2. Imitate white space gap when using flexbox - * 3. Style - */ - -.uk-subnav > * > :first-child { - /* 1 */ - display: flex; - align-items: center; - /* 2 */ - column-gap: 0.25em; - /* 3 */ - color: $subnav-item-color; - @if(mixin-exists(hook-subnav-item)) {@include hook-subnav-item();} -} - -/* Hover + Focus */ -.uk-subnav > * > a:hover, -.uk-subnav > * > a:focus { - color: $subnav-item-hover-color; - text-decoration: $subnav-item-hover-text-decoration; - outline: none; - @if(mixin-exists(hook-subnav-item-hover)) {@include hook-subnav-item-hover();} -} - -/* Active */ -.uk-subnav > .uk-active > a { - color: $subnav-item-active-color; - @if(mixin-exists(hook-subnav-item-active)) {@include hook-subnav-item-active();} -} - - -/* Divider modifier - ========================================================================== */ - -/* - * Set gutter - */ - -.uk-subnav-divider { margin-left: -(($subnav-divider-margin-horizontal * 2) + $subnav-divider-border-width); } - -/* - * Align items and divider vertically - */ - -.uk-subnav-divider > * { - display: flex; - align-items: center; -} - -/* - * Divider - * 1. `nth-child` makes it also work without JS if it's only one row - */ - -.uk-subnav-divider > ::before { - content: ""; - height: $subnav-divider-border-height; - margin-left: ($subnav-divider-margin-horizontal - $subnav-margin-horizontal); - margin-right: $subnav-divider-margin-horizontal; - border-left: $subnav-divider-border-width solid transparent; -} - -/* 1 */ -.uk-subnav-divider > :nth-child(n+2):not(.uk-first-column)::before { - border-left-color: $subnav-divider-border; - @if(mixin-exists(hook-subnav-divider)) {@include hook-subnav-divider();} -} - - -/* Pill modifier - ========================================================================== */ - -.uk-subnav-pill > * > :first-child { - padding: $subnav-pill-item-padding-vertical $subnav-pill-item-padding-horizontal; - background: $subnav-pill-item-background; - color: $subnav-pill-item-color; - @if(mixin-exists(hook-subnav-pill-item)) {@include hook-subnav-pill-item();} -} - -/* Hover + Focus */ -.uk-subnav-pill > * > a:hover, -.uk-subnav-pill > * > a:focus { - background-color: $subnav-pill-item-hover-background; - color: $subnav-pill-item-hover-color; - @if(mixin-exists(hook-subnav-pill-item-hover)) {@include hook-subnav-pill-item-hover();} -} - -/* OnClick */ -.uk-subnav-pill > * > a:active { - background-color: $subnav-pill-item-onclick-background; - color: $subnav-pill-item-onclick-color; - @if(mixin-exists(hook-subnav-pill-item-onclick)) {@include hook-subnav-pill-item-onclick();} -} - -/* Active */ -.uk-subnav-pill > .uk-active > a { - background-color: $subnav-pill-item-active-background; - color: $subnav-pill-item-active-color; - @if(mixin-exists(hook-subnav-pill-item-active)) {@include hook-subnav-pill-item-active();} -} - - -/* Disabled - * The same for all style modifiers - ========================================================================== */ - -.uk-subnav > .uk-disabled > a { - color: $subnav-item-disabled-color; - @if(mixin-exists(hook-subnav-item-disabled)) {@include hook-subnav-item-disabled();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-subnav-misc)) {@include hook-subnav-misc();} - -// @mixin hook-subnav(){} -// @mixin hook-subnav-item(){} -// @mixin hook-subnav-item-hover(){} -// @mixin hook-subnav-item-active(){} -// @mixin hook-subnav-divider(){} -// @mixin hook-subnav-pill-item(){} -// @mixin hook-subnav-pill-item-hover(){} -// @mixin hook-subnav-pill-item-onclick(){} -// @mixin hook-subnav-pill-item-active(){} -// @mixin hook-subnav-item-disabled(){} -// @mixin hook-subnav-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-subnav-item-color: $inverse-global-muted-color !default; -$inverse-subnav-item-hover-color: $inverse-global-color !default; -$inverse-subnav-item-active-color: $inverse-global-emphasis-color !default; -$inverse-subnav-divider-border: $inverse-global-border !default; -$inverse-subnav-pill-item-background: transparent !default; -$inverse-subnav-pill-item-color: $inverse-global-muted-color !default; -$inverse-subnav-pill-item-hover-background: $inverse-global-muted-background !default; -$inverse-subnav-pill-item-hover-color: $inverse-global-color !default; -$inverse-subnav-pill-item-onclick-background: $inverse-subnav-pill-item-hover-background !default; -$inverse-subnav-pill-item-onclick-color: $inverse-subnav-pill-item-hover-color !default; -$inverse-subnav-pill-item-active-background: $inverse-global-primary-background !default; -$inverse-subnav-pill-item-active-color: $inverse-global-inverse-color !default; -$inverse-subnav-item-disabled-color: $inverse-global-muted-color !default; - - - -// @mixin hook-inverse-subnav-item(){} -// @mixin hook-inverse-subnav-item-hover(){} -// @mixin hook-inverse-subnav-item-active(){} -// @mixin hook-inverse-subnav-divider(){} -// @mixin hook-inverse-subnav-pill-item(){} -// @mixin hook-inverse-subnav-pill-item-hover(){} -// @mixin hook-inverse-subnav-pill-item-onclick(){} -// @mixin hook-inverse-subnav-pill-item-active(){} -// @mixin hook-inverse-subnav-item-disabled(){} diff --git a/docs/_sass/uikit/components/svg.scss b/docs/_sass/uikit/components/svg.scss deleted file mode 100644 index bcf804af07..0000000000 --- a/docs/_sass/uikit/components/svg.scss +++ /dev/null @@ -1,36 +0,0 @@ -// Name: SVG -// Description: Component to style SVGs -// -// Component: `uk-svg` -// -// ======================================================================== - - -/* ======================================================================== - Component: SVG - ========================================================================== */ - -/* - * 1. Fill all SVG elements with the current text color if no `fill` attribute is set - * 2. Set the fill and stroke color of all SVG elements to the current text color - */ - -/* 1 */ -.uk-svg, -/* 2 */ -.uk-svg:not(.uk-preserve) [fill*='#']:not(.uk-preserve) { fill: currentcolor; } -.uk-svg:not(.uk-preserve) [stroke*='#']:not(.uk-preserve) { stroke: currentcolor; } - -/* - * Fix Firefox blurry SVG rendering: https://bugzilla.mozilla.org/show_bug.cgi?id=1046835 - */ - -.uk-svg { transform: translate(0,0); } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-svg-misc)) {@include hook-svg-misc();} - -// @mixin hook-svg-misc(){} diff --git a/docs/_sass/uikit/components/switcher.scss b/docs/_sass/uikit/components/switcher.scss deleted file mode 100644 index 0d99cdf7b9..0000000000 --- a/docs/_sass/uikit/components/switcher.scss +++ /dev/null @@ -1,47 +0,0 @@ -// Name: Switcher -// Description: Component to navigate through different content panes -// -// Component: `uk-switcher` -// -// States: `uk-active` -// -// ======================================================================== - - -/* ======================================================================== - Component: Switcher - ========================================================================== */ - -/* - * Reset list - */ - -.uk-switcher { - margin: 0; - padding: 0; - list-style: none; -} - - -/* Items - ========================================================================== */ - -/* - * Hide not active items - */ - -.uk-switcher > :not(.uk-active) { display: none; } - -/* - * Remove margin from the last-child - */ - -.uk-switcher > * > :last-child { margin-bottom: 0; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-switcher-misc)) {@include hook-switcher-misc();} - -// @mixin hook-switcher-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/tab.scss b/docs/_sass/uikit/components/tab.scss deleted file mode 100644 index 16d297f617..0000000000 --- a/docs/_sass/uikit/components/tab.scss +++ /dev/null @@ -1,197 +0,0 @@ -// Name: Tab -// Description: Component to create a tabbed navigation -// -// Component: `uk-tab` -// -// Modifiers: `uk-tab-bottom` -// `uk-tab-left` -// `uk-tab-right` -// -// States: `uk-active` -// `uk-disabled` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$tab-margin-horizontal: 20px !default; - -$tab-item-padding-horizontal: 10px !default; -$tab-item-padding-vertical: 5px !default; -$tab-item-color: $global-muted-color !default; -$tab-item-hover-color: $global-color !default; -$tab-item-hover-text-decoration: none !default; -$tab-item-active-color: $global-emphasis-color !default; -$tab-item-disabled-color: $global-muted-color !default; - - -/* ======================================================================== - Component: Tab - ========================================================================== */ - -/* - * 1. Allow items to wrap into the next line - * 2. Gutter - * 3. Reset list - */ - -.uk-tab { - display: flex; - /* 1 */ - flex-wrap: wrap; - /* 2 */ - margin-left: (-$tab-margin-horizontal); - /* 3 */ - padding: 0; - list-style: none; - @if(mixin-exists(hook-tab)) {@include hook-tab();} -} - -/* - * 1. Space is allocated solely based on content dimensions: 0 0 auto - * 2. Gutter - * 3. Create position context for dropdowns - */ - -.uk-tab > * { - /* 1 */ - flex: none; - /* 2 */ - padding-left: $tab-margin-horizontal; - /* 3 */ - position: relative; -} - - -/* Items - ========================================================================== */ - -/* - * Items must target `a` elements to exclude other elements (e.g. dropdowns) - * 1. Center content vertically, e.g. an icon - * 2. Imitate white space gap when using flexbox - * 3. Center content if a width is set - * 4. Style - */ - -.uk-tab > * > a { - /* 1 */ - display: flex; - align-items: center; - /* 2 */ - column-gap: 0.25em; - /* 3 */ - justify-content: center; - /* 4 */ - padding: $tab-item-padding-vertical $tab-item-padding-horizontal; - color: $tab-item-color; - @if(mixin-exists(hook-tab-item)) {@include hook-tab-item();} -} - -/* Hover + Focus */ -.uk-tab > * > a:hover, -.uk-tab > * > a:focus { - color: $tab-item-hover-color; - text-decoration: $tab-item-hover-text-decoration; - @if(mixin-exists(hook-tab-item-hover)) {@include hook-tab-item-hover();} -} - -/* Active */ -.uk-tab > .uk-active > a { - color: $tab-item-active-color; - @if(mixin-exists(hook-tab-item-active)) {@include hook-tab-item-active();} -} - -/* Disabled */ -.uk-tab > .uk-disabled > a { - color: $tab-item-disabled-color; - @if(mixin-exists(hook-tab-item-disabled)) {@include hook-tab-item-disabled();} -} - - -/* Position modifier - ========================================================================== */ - -/* - * Bottom - */ - -.uk-tab-bottom { - @if(mixin-exists(hook-tab-bottom)) {@include hook-tab-bottom();} -} - -.uk-tab-bottom > * > a { - @if(mixin-exists(hook-tab-bottom-item)) {@include hook-tab-bottom-item();} -} - -/* - * Left + Right - * 1. Reset Gutter - */ - -.uk-tab-left, -.uk-tab-right { - flex-direction: column; - /* 1 */ - margin-left: 0; -} - -/* 1 */ -.uk-tab-left > *, -.uk-tab-right > * { padding-left: 0; } - -.uk-tab-left { - @if(mixin-exists(hook-tab-left)) {@include hook-tab-left();} -} - -.uk-tab-right { - @if(mixin-exists(hook-tab-right)) {@include hook-tab-right();} -} - -.uk-tab-left > * > a { - text-align: left; - @if(mixin-exists(hook-tab-left-item)) {@include hook-tab-left-item();} -} - -.uk-tab-right > * > a { - text-align: left; - @if(mixin-exists(hook-tab-right-item)) {@include hook-tab-right-item();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-tab-misc)) {@include hook-tab-misc();} - -// @mixin hook-tab(){} -// @mixin hook-tab-item(){} -// @mixin hook-tab-item-hover(){} -// @mixin hook-tab-item-active(){} -// @mixin hook-tab-item-disabled(){} -// @mixin hook-tab-bottom(){} -// @mixin hook-tab-bottom-item(){} -// @mixin hook-tab-left(){} -// @mixin hook-tab-left-item(){} -// @mixin hook-tab-right(){} -// @mixin hook-tab-right-item(){} -// @mixin hook-tab-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-tab-item-color: $inverse-global-muted-color !default; -$inverse-tab-item-hover-color: $inverse-global-color !default; -$inverse-tab-item-active-color: $inverse-global-emphasis-color !default; -$inverse-tab-item-disabled-color: $inverse-global-muted-color !default; - - - -// @mixin hook-inverse-tab(){} -// @mixin hook-inverse-tab-item(){} -// @mixin hook-inverse-tab-item-hover(){} -// @mixin hook-inverse-tab-item-active(){} -// @mixin hook-inverse-tab-item-disabled(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/table.scss b/docs/_sass/uikit/components/table.scss deleted file mode 100644 index 50e2f81abf..0000000000 --- a/docs/_sass/uikit/components/table.scss +++ /dev/null @@ -1,315 +0,0 @@ -// Name: Table -// Description: Styles for tables -// -// Component: `uk-table` -// -// Modifiers: `uk-table-middle` -// `uk-table-divider` -// `uk-table-striped` -// `uk-table-hover` -// `uk-table-small` -// `uk-table-justify` -// `uk-table-shrink` -// `uk-table-expand` -// `uk-table-link` -// `uk-table-responsive` -// -// States: `uk-active` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$table-margin-vertical: $global-margin !default; - -$table-cell-padding-vertical: 16px !default; -$table-cell-padding-horizontal: 12px !default; - -$table-header-cell-font-size: $global-font-size !default; -$table-header-cell-font-weight: bold !default; -$table-header-cell-color: $global-color !default; - -$table-footer-font-size: $global-small-font-size !default; - -$table-caption-font-size: $global-small-font-size !default; -$table-caption-color: $global-muted-color !default; - -$table-row-active-background: #ffd !default; - -$table-divider-border-width: $global-border-width !default; -$table-divider-border: $global-border !default; - -$table-striped-row-background: $global-muted-background !default; - -$table-hover-row-background: $table-row-active-background !default; - -$table-small-cell-padding-vertical: 10px !default; -$table-small-cell-padding-horizontal: 12px !default; - -$table-large-cell-padding-vertical: 22px !default; -$table-large-cell-padding-horizontal: 12px !default; - -$table-expand-min-width: 150px !default; - - -/* ======================================================================== - Component: Table - ========================================================================== */ - -/* - * 1. Remove most spacing between table cells. - * 2. Behave like a block element - * 3. Style - */ - -.uk-table { - /* 1 */ - border-collapse: collapse; - border-spacing: 0; - /* 2 */ - width: 100%; - /* 3 */ - margin-bottom: $table-margin-vertical; - @if(mixin-exists(hook-table)) {@include hook-table();} -} - -/* Add margin if adjacent element */ -* + .uk-table { margin-top: $table-margin-vertical; } - - -/* Header cell - ========================================================================== */ - -/* - * 1. Style - */ - -.uk-table th { - padding: $table-cell-padding-vertical $table-cell-padding-horizontal; - text-align: left; - vertical-align: bottom; - /* 1 */ - font-size: $table-header-cell-font-size; - font-weight: $table-header-cell-font-weight; - color: $table-header-cell-color; - @if(mixin-exists(hook-table-header-cell)) {@include hook-table-header-cell();} -} - - -/* Cell - ========================================================================== */ - -.uk-table td { - padding: $table-cell-padding-vertical $table-cell-padding-horizontal; - vertical-align: top; - @if(mixin-exists(hook-table-cell)) {@include hook-table-cell();} -} - -/* - * Remove margin from the last-child - */ - -.uk-table td > :last-child { margin-bottom: 0; } - - -/* Footer - ========================================================================== */ - -.uk-table tfoot { - font-size: $table-footer-font-size; - @if(mixin-exists(hook-table-footer)) {@include hook-table-footer();} -} - - -/* Caption - ========================================================================== */ - -.uk-table caption { - font-size: $table-caption-font-size; - text-align: left; - color: $table-caption-color; - @if(mixin-exists(hook-table-caption)) {@include hook-table-caption();} -} - - -/* Alignment modifier - ========================================================================== */ - -.uk-table-middle, -.uk-table-middle td { vertical-align: middle !important; } - - -/* Style modifiers - ========================================================================== */ - -/* - * Divider - */ - -.uk-table-divider > tr:not(:first-child), -.uk-table-divider > :not(:first-child) > tr, -.uk-table-divider > :first-child > tr:not(:first-child) { - border-top: $table-divider-border-width solid $table-divider-border; - @if(mixin-exists(hook-table-divider)) {@include hook-table-divider();} -} - -/* - * Striped - */ - -.uk-table-striped > tr:nth-of-type(odd), -.uk-table-striped tbody tr:nth-of-type(odd) { - background: $table-striped-row-background; - @if(mixin-exists(hook-table-striped)) {@include hook-table-striped();} -} - -/* - * Hover - */ - -.uk-table-hover > tr:hover, -.uk-table-hover tbody tr:hover { - background: $table-hover-row-background; - @if(mixin-exists(hook-table-hover)) {@include hook-table-hover();} -} - - -/* Active state - ========================================================================== */ - -.uk-table > tr.uk-active, -.uk-table tbody tr.uk-active { - background: $table-row-active-background; - @if(mixin-exists(hook-table-row-active)) {@include hook-table-row-active();} -} - -/* Size modifier - ========================================================================== */ - -.uk-table-small th, -.uk-table-small td { - padding: $table-small-cell-padding-vertical $table-small-cell-padding-horizontal; - @if(mixin-exists(hook-table-small)) {@include hook-table-small();} -} - -.uk-table-large th, -.uk-table-large td { - padding: $table-large-cell-padding-vertical $table-large-cell-padding-horizontal; - @if(mixin-exists(hook-table-large)) {@include hook-table-large();} -} - - -/* Justify modifier - ========================================================================== */ - -.uk-table-justify th:first-child, -.uk-table-justify td:first-child { padding-left: 0; } - -.uk-table-justify th:last-child, -.uk-table-justify td:last-child { padding-right: 0; } - - -/* Cell size modifier - ========================================================================== */ - -.uk-table-shrink { width: 1px; } -.uk-table-expand { min-width: $table-expand-min-width; } - - -/* Cell link modifier - ========================================================================== */ - -/* - * Does not work with `uk-table-justify` at the moment - */ - -.uk-table-link { padding: 0 !important; } - -.uk-table-link > a { - display: block; - padding: $table-cell-padding-vertical $table-cell-padding-horizontal; -} - -.uk-table-small .uk-table-link > a { padding: $table-small-cell-padding-vertical $table-small-cell-padding-horizontal; } - - -/* Responsive table - ========================================================================== */ - - -/* Phone landscape and smaller */ -@media (max-width: $breakpoint-small-max) { - - .uk-table-responsive, - .uk-table-responsive tbody, - .uk-table-responsive th, - .uk-table-responsive td, - .uk-table-responsive tr { display: block; } - - .uk-table-responsive thead { display: none; } - - .uk-table-responsive th, - .uk-table-responsive td { - width: auto !important; - max-width: none !important; - min-width: 0 !important; - overflow: visible !important; - white-space: normal !important; - } - - .uk-table-responsive th:not(:first-child):not(.uk-table-link), - .uk-table-responsive td:not(:first-child):not(.uk-table-link), - .uk-table-responsive .uk-table-link:not(:first-child) > a { padding-top: round($table-cell-padding-vertical / 3) !important; } - - .uk-table-responsive th:not(:last-child):not(.uk-table-link), - .uk-table-responsive td:not(:last-child):not(.uk-table-link), - .uk-table-responsive .uk-table-link:not(:last-child) > a { padding-bottom: round($table-cell-padding-vertical / 3) !important; } - - .uk-table-justify.uk-table-responsive th, - .uk-table-justify.uk-table-responsive td { - padding-left: 0; - padding-right: 0; - } - -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-table-misc)) {@include hook-table-misc();} - -// @mixin hook-table(){} -// @mixin hook-table-header-cell(){} -// @mixin hook-table-cell(){} -// @mixin hook-table-footer(){} -// @mixin hook-table-caption(){} -// @mixin hook-table-row-active(){} -// @mixin hook-table-divider(){} -// @mixin hook-table-striped(){} -// @mixin hook-table-hover(){} -// @mixin hook-table-small(){} -// @mixin hook-table-large(){} -// @mixin hook-table-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-table-header-cell-color: $inverse-global-color !default; -$inverse-table-caption-color: $inverse-global-muted-color !default; -$inverse-table-row-active-background: fade-out($inverse-global-muted-background, 0.02) !default; -$inverse-table-divider-border: $inverse-global-border !default; -$inverse-table-striped-row-background: $inverse-global-muted-background !default; -$inverse-table-hover-row-background: $inverse-table-row-active-background !default; - - - -// @mixin hook-inverse-table-header-cell(){} -// @mixin hook-inverse-table-caption(){} -// @mixin hook-inverse-table-row-active(){} -// @mixin hook-inverse-table-divider(){} -// @mixin hook-inverse-table-striped(){} -// @mixin hook-inverse-table-hover(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/text.scss b/docs/_sass/uikit/components/text.scss deleted file mode 100644 index 25f5df2203..0000000000 --- a/docs/_sass/uikit/components/text.scss +++ /dev/null @@ -1,284 +0,0 @@ -// Name: Text -// Description: Utilities for text -// -// Component: `uk-text-*` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$text-lead-font-size: $global-large-font-size !default; -$text-lead-line-height: 1.5 !default; -$text-lead-color: $global-emphasis-color !default; - -$text-meta-font-size: $global-small-font-size !default; -$text-meta-line-height: 1.4 !default; -$text-meta-color: $global-muted-color !default; - -$text-small-font-size: $global-small-font-size !default; -$text-small-line-height: 1.5 !default; - -$text-large-font-size: $global-large-font-size !default; -$text-large-line-height: 1.5 !default; - -$text-muted-color: $global-muted-color !default; -$text-emphasis-color: $global-emphasis-color !default; -$text-primary-color: $global-primary-background !default; -$text-secondary-color: $global-secondary-background !default; -$text-success-color: $global-success-background !default; -$text-warning-color: $global-warning-background !default; -$text-danger-color: $global-danger-background !default; - -$text-background-color: $global-primary-background !default; - - -/* ======================================================================== - Component: Text - ========================================================================== */ - - -/* Style modifiers - ========================================================================== */ - -.uk-text-lead { - font-size: $text-lead-font-size; - line-height: $text-lead-line-height; - color: $text-lead-color; - @if(mixin-exists(hook-text-lead)) {@include hook-text-lead();} -} - -.uk-text-meta { - font-size: $text-meta-font-size; - line-height: $text-meta-line-height; - color: $text-meta-color; - @if(mixin-exists(hook-text-meta)) {@include hook-text-meta();} -} - - -/* Size modifiers - ========================================================================== */ - -.uk-text-small { - font-size: $text-small-font-size; - line-height: $text-small-line-height; - @if(mixin-exists(hook-text-small)) {@include hook-text-small();} -} - -.uk-text-large { - font-size: $text-large-font-size; - line-height: $text-large-line-height; - @if(mixin-exists(hook-text-large)) {@include hook-text-large();} -} - -.uk-text-default { - font-size: $global-font-size; - line-height: $global-line-height; -} - - -/* Weight modifier - ========================================================================== */ - -.uk-text-light { font-weight: 300; } -.uk-text-normal { font-weight: 400; } -.uk-text-bold { font-weight: 700; } - -.uk-text-lighter { font-weight: lighter; } -.uk-text-bolder { font-weight: bolder; } - - -/* Style modifier - ========================================================================== */ - -.uk-text-italic { font-style: italic; } - - -/* Transform modifier - ========================================================================== */ - -.uk-text-capitalize { text-transform: capitalize !important; } -.uk-text-uppercase { text-transform: uppercase !important; } -.uk-text-lowercase { text-transform: lowercase !important; } - - -/* Decoration modifier - ========================================================================== */ - -.uk-text-decoration-none { text-decoration: none !important; } - - -/* Color modifiers - ========================================================================== */ - -.uk-text-muted { color: $text-muted-color !important; } -.uk-text-emphasis { color: $text-emphasis-color !important; } -.uk-text-primary { color: $text-primary-color !important; } -.uk-text-secondary { color: $text-secondary-color !important; } -.uk-text-success { color: $text-success-color !important; } -.uk-text-warning { color: $text-warning-color !important; } -.uk-text-danger { color: $text-danger-color !important; } - - -/* Background modifier - ========================================================================== */ - -/* - * 1. The background clips to the foreground text. Works in Chrome, Firefox, Safari, Edge and Opera - * Default color is set to transparent - * 2. Container fits the text - * 3. Fallback color for IE11 - */ - -.uk-text-background { - /* 1 */ - -webkit-background-clip: text; - /* 2 */ - display: inline-block; - /* 3 */ - color: $text-background-color !important; -} - -@supports (-webkit-background-clip: text) { - - .uk-text-background { - background-color: $text-background-color; - color: transparent !important; - @if(mixin-exists(hook-text-background)) {@include hook-text-background();} - } - -} - - -/* Alignment modifiers - ========================================================================== */ - -.uk-text-left { text-align: left !important; } -.uk-text-right { text-align: right !important; } -.uk-text-center { text-align: center !important; } -.uk-text-justify { text-align: justify !important; } - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-text-left\@s { text-align: left !important; } - .uk-text-right\@s { text-align: right !important; } - .uk-text-center\@s { text-align: center !important; } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-text-left\@m { text-align: left !important; } - .uk-text-right\@m { text-align: right !important; } - .uk-text-center\@m { text-align: center !important; } - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-text-left\@l { text-align: left !important; } - .uk-text-right\@l { text-align: right !important; } - .uk-text-center\@l { text-align: center !important; } - -} - -/* Large screen and bigger */ -@media (min-width: $breakpoint-xlarge) { - - .uk-text-left\@xl { text-align: left !important; } - .uk-text-right\@xl { text-align: right !important; } - .uk-text-center\@xl { text-align: center !important; } - -} - -/* - * Vertical - */ - -.uk-text-top { vertical-align: top !important; } -.uk-text-middle { vertical-align: middle !important; } -.uk-text-bottom { vertical-align: bottom !important; } -.uk-text-baseline { vertical-align: baseline !important; } - - -/* Wrap modifiers - ========================================================================== */ - -/* - * Prevent text from wrapping onto multiple lines - */ - -.uk-text-nowrap { white-space: nowrap; } - -/* - * 1. Make sure a max-width is set after which truncation can occur - * 2. Prevent text from wrapping onto multiple lines, and truncate with an ellipsis - * 3. Fix for table cells - */ - -.uk-text-truncate { - /* 1 */ - max-width: 100%; - /* 2 */ - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* 2 */ -th.uk-text-truncate, -td.uk-text-truncate { max-width: 0; } - - -/* - * 1. Wrap long words onto the next line and break them if they are too long to fit - * 2. Legacy `word-wrap` as fallback for `overflow-wrap` - * 3. Fix `overflow-wrap` which doesn't work with table cells in Chrome, Opera, IE11 and Edge - * Must use `break-all` to support IE11 and Edge - * Note: Not using `hyphens: auto;` because it hyphenates text even if not needed - */ - -.uk-text-break { - /* 1 */ - overflow-wrap: break-word; - /* 2 */ - word-wrap: break-word; -} - -/* 3 */ -th.uk-text-break, -td.uk-text-break { word-break: break-all; } - - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-text-misc)) {@include hook-text-misc();} - -// @mixin hook-text-lead(){} -// @mixin hook-text-meta(){} -// @mixin hook-text-small(){} -// @mixin hook-text-large(){} -// @mixin hook-text-background(){} -// @mixin hook-text-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-text-lead-color: $inverse-global-color !default; -$inverse-text-meta-color: $inverse-global-muted-color !default; -$inverse-text-muted-color: $inverse-global-muted-color !default; -$inverse-text-emphasis-color: $inverse-global-emphasis-color !default; -$inverse-text-primary-color: $inverse-global-primary-background !default; -$inverse-text-secondary-color: $inverse-global-primary-background !default; - - - -// @mixin hook-inverse-text-lead(){} -// @mixin hook-inverse-text-meta(){} diff --git a/docs/_sass/uikit/components/thumbnav.scss b/docs/_sass/uikit/components/thumbnav.scss deleted file mode 100644 index ce211a1f52..0000000000 --- a/docs/_sass/uikit/components/thumbnav.scss +++ /dev/null @@ -1,121 +0,0 @@ -// Name: Thumbnav -// Description: Component to create thumbnail navigations -// -// Component: `uk-thumbnav` -// -// Modifier: `uk-thumbnav-vertical` -// -// States: `uk-active` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$thumbnav-margin-horizontal: 15px !default; -$thumbnav-margin-vertical: $thumbnav-margin-horizontal !default; - - -/* ======================================================================== - Component: Thumbnav - ========================================================================== */ - -/* - * 1. Allow items to wrap into the next line - * 2. Reset list - * 3. Gutter - */ - -.uk-thumbnav { - display: flex; - /* 1 */ - flex-wrap: wrap; - /* 2 */ - margin: 0; - padding: 0; - list-style: none; - /* 3 */ - margin-left: (-$thumbnav-margin-horizontal); - @if(mixin-exists(hook-thumbnav)) {@include hook-thumbnav();} -} - -/* - * Space is allocated based on content dimensions, but shrinks: 0 1 auto - * 1. Gutter - */ - -.uk-thumbnav > * { - /* 1 */ - padding-left: $thumbnav-margin-horizontal; -} - - -/* Items - ========================================================================== */ - -/* - * Items - */ - -.uk-thumbnav > * > * { - display: inline-block; - @if(mixin-exists(hook-thumbnav-item)) {@include hook-thumbnav-item();} -} - -/* Hover + Focus */ -.uk-thumbnav > * > :hover, -.uk-thumbnav > * > :focus { - outline: none; - @if(mixin-exists(hook-thumbnav-item-hover)) {@include hook-thumbnav-item-hover();} -} - -/* Active */ -.uk-thumbnav > .uk-active > * { - @if(mixin-exists(hook-thumbnav-item-active)) {@include hook-thumbnav-item-active();} -} - - -/* Modifier: 'uk-thumbnav-vertical' - ========================================================================== */ - -/* - * 1. Change direction - * 2. Gutter - */ - -.uk-thumbnav-vertical { - /* 1 */ - flex-direction: column; - /* 2 */ - margin-left: 0; - margin-top: (-$thumbnav-margin-vertical); -} - -/* 2 */ -.uk-thumbnav-vertical > * { - padding-left: 0; - padding-top: $thumbnav-margin-vertical; -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-thumbnav-misc)) {@include hook-thumbnav-misc();} - -// @mixin hook-thumbnav(){} -// @mixin hook-thumbnav-item(){} -// @mixin hook-thumbnav-item-hover(){} -// @mixin hook-thumbnav-item-active(){} -// @mixin hook-thumbnav-misc(){} - - -// Inverse -// ======================================================================== - - - -// @mixin hook-inverse-thumbnav-item(){} -// @mixin hook-inverse-thumbnav-item-hover(){} -// @mixin hook-inverse-thumbnav-item-active(){} \ No newline at end of file diff --git a/docs/_sass/uikit/components/tile.scss b/docs/_sass/uikit/components/tile.scss deleted file mode 100644 index 129ee19786..0000000000 --- a/docs/_sass/uikit/components/tile.scss +++ /dev/null @@ -1,213 +0,0 @@ -// Name: Tile -// Description: Component to create tiled boxes -// -// Component: `uk-tile` -// -// Modifiers: `uk-tile-xsmall` -// `uk-tile-small` -// `uk-tile-large` -// `uk-tile-xlarge` -// `uk-tile-default` -// `uk-tile-muted` -// `uk-tile-primary` -// `uk-tile-secondary` -// -// States: `uk-preserve-color` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$tile-padding-horizontal: 15px !default; -$tile-padding-horizontal-s: $global-gutter !default; -$tile-padding-horizontal-m: $global-medium-gutter !default; -$tile-padding-vertical: $global-medium-margin !default; -$tile-padding-vertical-m: $global-large-margin !default; - -$tile-xsmall-padding-vertical: $global-margin !default; - -$tile-small-padding-vertical: $global-medium-margin !default; - -$tile-large-padding-vertical: $global-large-margin !default; -$tile-large-padding-vertical-m: $global-xlarge-margin !default; - -$tile-xlarge-padding-vertical: $global-xlarge-margin !default; -$tile-xlarge-padding-vertical-m: ($global-large-margin + $global-xlarge-margin) !default; - -$tile-default-background: $global-background !default; - -$tile-muted-background: $global-muted-background !default; - -$tile-primary-background: $global-primary-background !default; -$tile-primary-color-mode: light !default; - -$tile-secondary-background: $global-secondary-background !default; -$tile-secondary-color-mode: light !default; - - -/* ======================================================================== - Component: Tile - ========================================================================== */ - -.uk-tile { - display: flow-root; - position: relative; - box-sizing: border-box; - padding-left: $tile-padding-horizontal; - padding-right: $tile-padding-horizontal; - padding-top: $tile-padding-vertical; - padding-bottom: $tile-padding-vertical; - @if(mixin-exists(hook-tile)) {@include hook-tile();} -} - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-tile { - padding-left: $tile-padding-horizontal-s; - padding-right: $tile-padding-horizontal-s; - } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-tile { - padding-left: $tile-padding-horizontal-m; - padding-right: $tile-padding-horizontal-m; - padding-top: $tile-padding-vertical-m; - padding-bottom: $tile-padding-vertical-m; - } - -} - -/* - * Remove margin from the last-child - */ - -.uk-tile > :last-child { margin-bottom: 0; } - - -/* Size modifiers - ========================================================================== */ - -/* - * XSmall - */ - -.uk-tile-xsmall { - padding-top: $tile-xsmall-padding-vertical; - padding-bottom: $tile-xsmall-padding-vertical; -} - -/* - * Small - */ - -.uk-tile-small { - padding-top: $tile-small-padding-vertical; - padding-bottom: $tile-small-padding-vertical; -} - -/* - * Large - */ - -.uk-tile-large { - padding-top: $tile-large-padding-vertical; - padding-bottom: $tile-large-padding-vertical; -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-tile-large { - padding-top: $tile-large-padding-vertical-m; - padding-bottom: $tile-large-padding-vertical-m; - } - -} - - -/* - * XLarge - */ - -.uk-tile-xlarge { - padding-top: $tile-xlarge-padding-vertical; - padding-bottom: $tile-xlarge-padding-vertical; -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-tile-xlarge { - padding-top: $tile-xlarge-padding-vertical-m; - padding-bottom: $tile-xlarge-padding-vertical-m; - } - -} - - -/* Style modifiers - ========================================================================== */ - -/* - * Default - */ - -.uk-tile-default { - background: $tile-default-background; - @if(mixin-exists(hook-tile-default)) {@include hook-tile-default();} -} - -/* - * Muted - */ - -.uk-tile-muted { - background: $tile-muted-background; - @if(mixin-exists(hook-tile-muted)) {@include hook-tile-muted();} -} - -/* - * Primary - */ - -.uk-tile-primary { - background: $tile-primary-background; - @if(mixin-exists(hook-tile-primary)) {@include hook-tile-primary();} -} - -// Color Mode -@if ( $tile-primary-color-mode == light ) { .uk-tile-primary:not(.uk-preserve-color) { @extend .uk-light !optional;} } -@if ( $tile-primary-color-mode == dark ) { .uk-tile-primary:not(.uk-preserve-color) { @extend .uk-dark !optional;} } - -/* - * Secondary - */ - -.uk-tile-secondary { - background: $tile-secondary-background; - @if(mixin-exists(hook-tile-secondary)) {@include hook-tile-secondary();} -} - -// Color Mode -@if ( $tile-secondary-color-mode == light ) { .uk-tile-secondary:not(.uk-preserve-color) { @extend .uk-light !optional;} } -@if ( $tile-secondary-color-mode == dark ) { .uk-tile-secondary:not(.uk-preserve-color) { @extend .uk-dark !optional;} } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-tile-misc)) {@include hook-tile-misc();} - -// @mixin hook-tile(){} -// @mixin hook-tile-default(){} -// @mixin hook-tile-muted(){} -// @mixin hook-tile-primary(){} -// @mixin hook-tile-secondary(){} -// @mixin hook-tile-misc(){} diff --git a/docs/_sass/uikit/components/tooltip.scss b/docs/_sass/uikit/components/tooltip.scss deleted file mode 100644 index 416d289e4c..0000000000 --- a/docs/_sass/uikit/components/tooltip.scss +++ /dev/null @@ -1,87 +0,0 @@ -// Name: Tooltip -// Description: Component to create tooltips -// -// Component: `uk-tooltip` -// -// Modifiers `uk-tooltip-top` -// `uk-tooltip-top-left` -// `uk-tooltip-top-right` -// `uk-tooltip-bottom` -// `uk-tooltip-bottom-left` -// `uk-tooltip-bottom-right` -// `uk-tooltip-left` -// `uk-tooltip-right` -// -// States: `uk-active` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$tooltip-z-index: $global-z-index + 30 !default; -$tooltip-max-width: 200px !default; -$tooltip-padding-vertical: 3px !default; -$tooltip-padding-horizontal: 6px !default; -$tooltip-background: #666 !default; -$tooltip-border-radius: 2px !default; -$tooltip-color: $global-inverse-color !default; -$tooltip-font-size: 12px !default; - -$tooltip-margin: 10px !default; - - -/* ======================================================================== - Component: Tooltip - ========================================================================== */ - -/* - * 1. Hide by default - * 2. Position - * 3. Remove tooltip from document flow to keep the UIkit container from changing its size when injected into the document initially - * 4. Dimensions - * 5. Style - */ - -.uk-tooltip { - /* 1 */ - display: none; - /* 2 */ - position: absolute; - z-index: $tooltip-z-index; - /* 3 */ - top: 0; - /* 4 */ - box-sizing: border-box; - max-width: $tooltip-max-width; - padding: $tooltip-padding-vertical $tooltip-padding-horizontal; - /* 5 */ - background: $tooltip-background; - border-radius: $tooltip-border-radius; - color: $tooltip-color; - font-size: $tooltip-font-size; - @if(mixin-exists(hook-tooltip)) {@include hook-tooltip();} -} - -/* Show */ -.uk-tooltip.uk-active { display: block; } - - -/* Direction / Alignment modifiers - ========================================================================== */ - -/* Direction */ -[class*='uk-tooltip-top'] { margin-top: (-$tooltip-margin); } -[class*='uk-tooltip-bottom'] { margin-top: $tooltip-margin; } -[class*='uk-tooltip-left'] { margin-left: (-$tooltip-margin); } -[class*='uk-tooltip-right'] { margin-left: $tooltip-margin; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-tooltip-misc)) {@include hook-tooltip-misc();} - -// @mixin hook-tooltip(){} -// @mixin hook-tooltip-misc(){} diff --git a/docs/_sass/uikit/components/totop.scss b/docs/_sass/uikit/components/totop.scss deleted file mode 100644 index 4b8aa1d88f..0000000000 --- a/docs/_sass/uikit/components/totop.scss +++ /dev/null @@ -1,71 +0,0 @@ -// Name: Totop -// Description: Component to create an icon to scroll back to top -// -// Component: `uk-totop` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$totop-padding: 5px !default; -$totop-color: $global-muted-color !default; - -$totop-hover-color: $global-color !default; - -$totop-active-color: $global-emphasis-color !default; - - -/* ======================================================================== - Component: Totop - ========================================================================== */ - -/* - * Addopts `uk-icon` - */ - -.uk-totop { - padding: $totop-padding; - color: $totop-color; - @if(mixin-exists(hook-totop)) {@include hook-totop();} -} - -/* Hover + Focus */ -.uk-totop:hover, -.uk-totop:focus { - color: $totop-hover-color; - outline: none; - @if(mixin-exists(hook-totop-hover)) {@include hook-totop-hover();} -} - -/* OnClick */ -.uk-totop:active { - color: $totop-active-color; - @if(mixin-exists(hook-totop-active)) {@include hook-totop-active();} -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-totop-misc)) {@include hook-totop-misc();} - -// @mixin hook-totop(){} -// @mixin hook-totop-hover(){} -// @mixin hook-totop-active(){} -// @mixin hook-totop-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-totop-color: $inverse-global-muted-color !default; -$inverse-totop-hover-color: $inverse-global-color !default; -$inverse-totop-active-color: $inverse-global-emphasis-color !default; - - - -// @mixin hook-inverse-totop(){} -// @mixin hook-inverse-totop-hover(){} -// @mixin hook-inverse-totop-active(){} diff --git a/docs/_sass/uikit/components/transition.scss b/docs/_sass/uikit/components/transition.scss deleted file mode 100644 index abcf794e15..0000000000 --- a/docs/_sass/uikit/components/transition.scss +++ /dev/null @@ -1,157 +0,0 @@ -// Name: Transition -// Description: Utilities for transitions -// -// Component: `uk-transition-*` -// -// Modifiers: `uk-transition-fade` -// `uk-transition-scale-up` -// `uk-transition-scale-down` -// `uk-transition-slide-top-*` -// `uk-transition-slide-bottom-*` -// `uk-transition-slide-left-*` -// `uk-transition-slide-right-*` -// `uk-transition-opaque` -// `uk-transition-slow` -// -// Sub-objects: `uk-transition-toggle`, -// `uk-transition-active` -// -// States: `uk-active` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$transition-duration: 0.3s !default; - -$transition-scale: 1.03 !default; - -$transition-slide-small-translate: 10px !default; -$transition-slide-medium-translate: 50px !default; - -$transition-slow-duration: 0.7s !default; - - -/* ======================================================================== - Component: Transition - ========================================================================== */ - - -/* Toggle (Hover + Focus) - ========================================================================== */ - -/* - * 1. Prevent tab highlighting on iOS. - */ - -.uk-transition-toggle { - /* 1 */ - -webkit-tap-highlight-color: transparent; -} - -/* - * Remove outline for `tabindex` - */ - -.uk-transition-toggle:focus { outline: none; } - - -/* Transitions - ========================================================================== */ - -/* - * The toggle is triggered on touch devices by two methods: - * 1. Using `:focus` and tabindex - * 2. Using `:hover` and a `touchstart` event listener registered on the document - * (Doesn't work on Surface touch devices) - * - * Note: Transitions don't work with `uk-postion-center-*` classes because they also use `transform`, - * therefore it's recommended to use an extra `div` for the transition. - */ - -.uk-transition-fade, -[class*='uk-transition-scale'], -[class*='uk-transition-slide'] { - transition: $transition-duration ease-out; - transition-property: opacity, transform, filter; - opacity: 0; -} - -/* - * Fade - */ - -.uk-transition-toggle:hover .uk-transition-fade, -.uk-transition-toggle:focus .uk-transition-fade, -.uk-transition-active.uk-active .uk-transition-fade { opacity: 1; } - -/* - * Scale - */ - -.uk-transition-scale-up { transform: scale(1,1); } -.uk-transition-scale-down { transform: scale($transition-scale,$transition-scale); } - -/* Show */ -.uk-transition-toggle:hover .uk-transition-scale-up, -.uk-transition-toggle:focus .uk-transition-scale-up, -.uk-transition-active.uk-active .uk-transition-scale-up { - opacity: 1; - transform: scale($transition-scale,$transition-scale); -} - -.uk-transition-toggle:hover .uk-transition-scale-down, -.uk-transition-toggle:focus .uk-transition-scale-down, -.uk-transition-active.uk-active .uk-transition-scale-down { - opacity: 1; - transform: scale(1,1); -} - -/* - * Slide - */ - -.uk-transition-slide-top { transform: translateY(-100%); } -.uk-transition-slide-bottom { transform: translateY(100%); } -.uk-transition-slide-left { transform: translateX(-100%); } -.uk-transition-slide-right { transform: translateX(100%); } - -.uk-transition-slide-top-small { transform: translateY(-$transition-slide-small-translate); } -.uk-transition-slide-bottom-small { transform: translateY($transition-slide-small-translate); } -.uk-transition-slide-left-small { transform: translateX(-$transition-slide-small-translate); } -.uk-transition-slide-right-small { transform: translateX($transition-slide-small-translate); } - -.uk-transition-slide-top-medium { transform: translateY(-$transition-slide-medium-translate); } -.uk-transition-slide-bottom-medium { transform: translateY($transition-slide-medium-translate); } -.uk-transition-slide-left-medium { transform: translateX(-$transition-slide-medium-translate); } -.uk-transition-slide-right-medium { transform: translateX($transition-slide-medium-translate); } - -/* Show */ -.uk-transition-toggle:hover [class*='uk-transition-slide'], -.uk-transition-toggle:focus [class*='uk-transition-slide'], -.uk-transition-active.uk-active [class*='uk-transition-slide'] { - opacity: 1; - transform: translate(0,0); -} - - -/* Opacity modifier - ========================================================================== */ - -.uk-transition-opaque { opacity: 1; } - - -/* Duration modifiers - ========================================================================== */ - -.uk-transition-slow { transition-duration: $transition-slow-duration; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-transition-misc)) {@include hook-transition-misc();} - -// @mixin hook-transition-misc(){} diff --git a/docs/_sass/uikit/components/utility.scss b/docs/_sass/uikit/components/utility.scss deleted file mode 100644 index beb55562b2..0000000000 --- a/docs/_sass/uikit/components/utility.scss +++ /dev/null @@ -1,482 +0,0 @@ -// Name: Utility -// Description: Utilities collection -// -// Component: `uk-panel-*` -// `uk-clearfix` -// `uk-float-*` -// `uk-overflow-*` -// `uk-resize-*` -// `uk-display-*` -// `uk-inline-*` -// `uk-responsive-*` -// `uk-preserve-width` -// `uk-border-*` -// `uk-box-shadow-*` -// `uk-box-shadow-bottom` -// `uk-dropcap` -// `uk-logo` -// `uk-blend-*` -// `uk-transform-*` -// `uk-transform-origin-*` -// -// States: `uk-disabled` -// `uk-drag` -// `uk-dragover` -// `uk-preserve` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$panel-scrollable-height: 170px !default; -$panel-scrollable-padding: 10px !default; -$panel-scrollable-border-width: $global-border-width !default; -$panel-scrollable-border: $global-border !default; - -$border-rounded-border-radius: 5px !default; - -$box-shadow-duration: 0.1s !default; - -$box-shadow-bottom-height: 30px !default; -$box-shadow-bottom-border-radius: 100% !default; -$box-shadow-bottom-background: #444 !default; -$box-shadow-bottom-blur: 20px !default; - -$dropcap-margin-right: 10px !default; -$dropcap-font-size: (($global-line-height * 3) * 1em) !default; - -$logo-font-size: $global-large-font-size !default; -$logo-font-family: $global-font-family !default; -$logo-color: $global-color !default; -$logo-hover-color: $global-color !default; - -$dragover-box-shadow: 0 0 20px rgba(100,100,100,0.3) !default; - - -/* ======================================================================== - Component: Utility - ========================================================================== */ - - -/* Panel - ========================================================================== */ - -.uk-panel { - display: flow-root; - position: relative; - box-sizing: border-box; -} - -/* - * Remove margin from the last-child - */ - -.uk-panel > :last-child { margin-bottom: 0; } - - -/* - * Scrollable - */ - -.uk-panel-scrollable { - height: $panel-scrollable-height; - padding: $panel-scrollable-padding; - border: $panel-scrollable-border-width solid $panel-scrollable-border; - overflow: auto; - -webkit-overflow-scrolling: touch; - resize: both; - @if(mixin-exists(hook-panel-scrollable)) {@include hook-panel-scrollable();} -} - - -/* Clearfix - ========================================================================== */ - -/* - * 1. `table-cell` is used with `::before` because `table` creates a 1px gap when it becomes a flex item, only in Webkit - * 2. `table` is used again with `::after` because `clear` only works with block elements. - * Note: `display: block` with `overflow: hidden` is currently not working in the latest Safari - */ - -/* 1 */ -.uk-clearfix::before { - content: ""; - display: table-cell; -} - -/* 2 */ -.uk-clearfix::after { - content: ""; - display: table; - clear: both; -} - - -/* Float - ========================================================================== */ - -/* - * 1. Prevent content overflow - */ - -.uk-float-left { float: left; } -.uk-float-right { float: right; } - -/* 1 */ -[class*='uk-float-'] { max-width: 100%; } - - -/* Overfow - ========================================================================== */ - -.uk-overflow-hidden { overflow: hidden; } - -/* - * Enable scrollbars if content is clipped - * Note: Firefox ignores `padding-bottom` for the scrollable overflow https://bugzilla.mozilla.org/show_bug.cgi?id=748518 - */ - -.uk-overflow-auto { - overflow: auto; - -webkit-overflow-scrolling: touch; -} - -.uk-overflow-auto > :last-child { margin-bottom: 0; } - - -/* Resize - ========================================================================== */ - -.uk-resize { resize: both; } -.uk-resize-vertical { resize: vertical; } - - -/* Display - ========================================================================== */ - -.uk-display-block { display: block !important; } -.uk-display-inline { display: inline !important; } -.uk-display-inline-block { display: inline-block !important; } - - -/* Inline - ========================================================================== */ - -/* - * 1. Container fits its content - * 2. Create position context - * 3. Prevent content overflow - * 4. Behave like most inline-block elements - * 5. Force new layer without creating a new stacking context - * to fix 1px glitch when combined with overlays and transitions in Webkit - * 6. Clip child elements - */ - -[class*='uk-inline'] { - /* 1 */ - display: inline-block; - /* 2 */ - position: relative; - /* 3 */ - max-width: 100%; - /* 4 */ - vertical-align: middle; - /* 5 */ - -webkit-backface-visibility: hidden; -} - -.uk-inline-clip { - /* 6 */ - overflow: hidden; -} - - -/* Responsive objects - ========================================================================== */ - -/* - * Preserve original dimensions - * Because `img, `video`, `canvas` and `audio` are already responsive by default, see Base component - */ - -.uk-preserve-width, -.uk-preserve-width canvas, -.uk-preserve-width img, -.uk-preserve-width svg, -.uk-preserve-width video { max-width: none; } - -/* - * Responsiveness - * Corrects `max-width` and `max-height` behavior if padding and border are used - */ - -.uk-responsive-width, -.uk-responsive-height { box-sizing: border-box; } - -/* - * 1. Set a maximum width. `important` needed to override `uk-preserve-width img` - * 2. Auto scale the height. Only needed if `height` attribute is present - */ - -.uk-responsive-width { - /* 1 */ - max-width: 100% !important; - /* 2 */ - height: auto; -} - -/* - * 1. Set a maximum height. Only works if the parent element has a fixed height - * 2. Auto scale the width. Only needed if `width` attribute is present - * 3. Reset max-width, which `img, `video`, `canvas` and `audio` already have by default - */ - -.uk-responsive-height { - /* 1 */ - max-height: 100%; - /* 2 */ - width: auto; - /* 3 */ - max-width: none; -} - - -/* Border - ========================================================================== */ - -.uk-border-circle { border-radius: 50%; } -.uk-border-pill { border-radius: 500px; } -.uk-border-rounded { border-radius: $border-rounded-border-radius; } - -/* - * Fix `overflow: hidden` to be ignored with border-radius and CSS transforms in Webkit - */ - -.uk-inline-clip[class*='uk-border-'] { -webkit-transform: translateZ(0); } - - -/* Box-shadow - ========================================================================== */ - -.uk-box-shadow-small { box-shadow: $global-small-box-shadow; } -.uk-box-shadow-medium { box-shadow: $global-medium-box-shadow; } -.uk-box-shadow-large { box-shadow: $global-large-box-shadow; } -.uk-box-shadow-xlarge { box-shadow: $global-xlarge-box-shadow; } - -/* - * Hover - */ - -[class*='uk-box-shadow-hover'] { transition: box-shadow $box-shadow-duration ease-in-out; } - -.uk-box-shadow-hover-small:hover { box-shadow: $global-small-box-shadow; } -.uk-box-shadow-hover-medium:hover { box-shadow: $global-medium-box-shadow; } -.uk-box-shadow-hover-large:hover { box-shadow: $global-large-box-shadow; } -.uk-box-shadow-hover-xlarge:hover { box-shadow: $global-xlarge-box-shadow; } - - -/* Box-shadow bottom - ========================================================================== */ - -/* - * 1. Set position. - * 2. Set style - * 3. Fix shadow being clipped in Safari if container is animated - */ - -@supports (filter: blur(0)) { - - .uk-box-shadow-bottom { - display: inline-block; - position: relative; - z-index: 0; - max-width: 100%; - vertical-align: middle; - } - - .uk-box-shadow-bottom::after { - content: ''; - /* 1 */ - position: absolute; - bottom: (-$box-shadow-bottom-height); - left: 0; - right: 0; - z-index: -1; - /* 2 */ - height: $box-shadow-bottom-height; - border-radius: $box-shadow-bottom-border-radius; - background: $box-shadow-bottom-background; - filter: blur($box-shadow-bottom-blur); - /* 3 */ - will-change: filter; - @if(mixin-exists(hook-box-shadow-bottom)) {@include hook-box-shadow-bottom();} - } - -} - - -/* Drop cap - ========================================================================== */ - -/* - * 1. Firefox doesn't apply `::first-letter` if the first letter is inside child elements - * https://bugzilla.mozilla.org/show_bug.cgi?id=214004 - * 2. In Firefox, a floating `::first-letter` doesn't have a line box and there for no `line-height` - * https://bugzilla.mozilla.org/show_bug.cgi?id=317933 - * 3. Caused by 1.: Edge creates two nested `::first-letter` containers, one for each selector - * This doubles the `font-size` exponential when using the `em` unit. - */ - -.uk-dropcap::first-letter, -/* 1 */ -.uk-dropcap > p:first-of-type::first-letter { - display: block; - margin-right: $dropcap-margin-right; - float: left; - font-size: $dropcap-font-size; - line-height: 1; - @if(mixin-exists(hook-dropcap)) {@include hook-dropcap();} -} - -/* 2 */ -@-moz-document url-prefix() { - - .uk-dropcap::first-letter, - .uk-dropcap > p:first-of-type::first-letter { margin-top: 1.1%; } - -} - -/* 3 */ -@supports (-ms-ime-align: auto) { - - .uk-dropcap > p:first-of-type::first-letter { font-size: 1em; } - -} - - -/* Logo - ========================================================================== */ - -/* - * 1. Required for `a` - */ - -.uk-logo { - font-size: $logo-font-size; - font-family: $logo-font-family; - color: $logo-color; - /* 1 */ - text-decoration: none; - @if(mixin-exists(hook-logo)) {@include hook-logo();} -} - -/* Hover + Focus */ -.uk-logo:hover, -.uk-logo:focus { - color: $logo-hover-color; - outline: none; - /* 1 */ - text-decoration: none; - @if(mixin-exists(hook-logo-hover)) {@include hook-logo-hover();} -} - -.uk-logo-inverse { display: none; } - - -/* Disabled State - ========================================================================== */ - -.uk-disabled { pointer-events: none; } - - -/* Drag State - ========================================================================== */ - -/* - * 1. Needed if moving over elements with have their own cursor on hover, e.g. links or buttons - * 2. Fix dragging over iframes - */ - -.uk-drag, -/* 1 */ -.uk-drag * { cursor: move; } - -/* 2 */ -.uk-drag iframe { pointer-events: none; } - - -/* Dragover State - ========================================================================== */ - -/* - * Create a box-shadow when dragging a file over the upload area - */ - -.uk-dragover { box-shadow: $dragover-box-shadow; } - - -/* Blend modes - ========================================================================== */ - -.uk-blend-multiply { mix-blend-mode: multiply; } -.uk-blend-screen { mix-blend-mode: screen; } -.uk-blend-overlay { mix-blend-mode: overlay; } -.uk-blend-darken { mix-blend-mode: darken; } -.uk-blend-lighten { mix-blend-mode: lighten; } -.uk-blend-color-dodge { mix-blend-mode: color-dodge; } -.uk-blend-color-burn { mix-blend-mode: color-burn; } -.uk-blend-hard-light { mix-blend-mode: hard-light; } -.uk-blend-soft-light { mix-blend-mode: soft-light; } -.uk-blend-difference { mix-blend-mode: difference; } -.uk-blend-exclusion { mix-blend-mode: exclusion; } -.uk-blend-hue { mix-blend-mode: hue; } -.uk-blend-saturation { mix-blend-mode: saturation; } -.uk-blend-color { mix-blend-mode: color; } -.uk-blend-luminosity { mix-blend-mode: luminosity; } - - -/* Transform -========================================================================== */ - -.uk-transform-center { transform: translate(-50%, -50%); } - - -/* Transform Origin -========================================================================== */ - -.uk-transform-origin-top-left { transform-origin: 0 0; } -.uk-transform-origin-top-center { transform-origin: 50% 0; } -.uk-transform-origin-top-right { transform-origin: 100% 0; } -.uk-transform-origin-center-left { transform-origin: 0 50%; } -.uk-transform-origin-center-right { transform-origin: 100% 50%; } -.uk-transform-origin-bottom-left { transform-origin: 0 100%; } -.uk-transform-origin-bottom-center { transform-origin: 50% 100%; } -.uk-transform-origin-bottom-right { transform-origin: 100% 100%; } - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-utility-misc)) {@include hook-utility-misc();} - -// @mixin hook-panel-scrollable(){} -// @mixin hook-box-shadow-bottom(){} -// @mixin hook-dropcap(){} -// @mixin hook-logo(){} -// @mixin hook-logo-hover(){} -// @mixin hook-utility-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-logo-color: $inverse-global-color !default; -$inverse-logo-hover-color: $inverse-global-color !default; - - - -// @mixin hook-inverse-dropcap(){} -// @mixin hook-inverse-logo(){} -// @mixin hook-inverse-logo-hover(){} diff --git a/docs/_sass/uikit/components/variables.scss b/docs/_sass/uikit/components/variables.scss deleted file mode 100644 index 0d3b304e70..0000000000 --- a/docs/_sass/uikit/components/variables.scss +++ /dev/null @@ -1,123 +0,0 @@ -// -// Component: Variables -// Description: Defines common values which are used across all components -// -// ======================================================================== - - -// Load deprecated components -// ======================================================================== - -$deprecated: false !default; - - -// Breakpoints -// ======================================================================== - -// Phone Portrait: Galaxy (360x640), iPhone 6 (375x667), iPhone 6+ (414x736) -// Phone Landscape: Galaxy (640x360), iPhone 6 (667x375), iPhone 6+ (736x414) -// Tablet Portrait: iPad (768x1024), Galaxy Tab (800x1280), -// Tablet Landscape: iPad (1024x768), iPad Pro (1024x1366), -// Desktop: Galaxy Tab (1280x800), iPad Pro (1366x1024) - -$breakpoint-small: 640px !default; // Phone landscape -$breakpoint-medium: 960px !default; // Tablet Landscape -$breakpoint-large: 1200px !default; // Desktop -$breakpoint-xlarge: 1600px !default; // Large Screens - -$breakpoint-xsmall-max: ($breakpoint-small - 1) !default; -$breakpoint-small-max: ($breakpoint-medium - 1) !default; -$breakpoint-medium-max: ($breakpoint-large - 1) !default; -$breakpoint-large-max: ($breakpoint-xlarge - 1) !default; - - -// Global variables -// ======================================================================== - -// -// Typography -// - -$global-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default; -$global-font-size: 16px !default; -$global-line-height: 1.5 !default; // 24px - -$global-2xlarge-font-size: 2.625rem !default; // 42px -$global-xlarge-font-size: 2rem !default; // 32px -$global-large-font-size: 1.5rem !default; // 24px -$global-medium-font-size: 1.25rem !default; // 20px -$global-small-font-size: 0.875rem !default; // 14px - -// -// Colors -// - -$global-color: #666 !default; -$global-emphasis-color: #333 !default; -$global-muted-color: #999 !default; - -$global-link-color: #1e87f0 !default; -$global-link-hover-color: #0f6ecd !default; - -$global-inverse-color: #fff !default; - -// -// Backgrounds -// - -$global-background: #fff !default; - -$global-muted-background: #f8f8f8 !default; -$global-primary-background: #1e87f0 !default; -$global-secondary-background: #222 !default; - -$global-success-background: #32d296 !default; -$global-warning-background: #faa05a !default; -$global-danger-background: #f0506e !default; - -// -// Borders -// - -$global-border-width: 1px !default; -$global-border: #e5e5e5 !default; - -// -// Box-Shadows -// - -$global-small-box-shadow: 0 2px 8px rgba(0,0,0,0.08) !default; -$global-medium-box-shadow: 0 5px 15px rgba(0,0,0,0.08) !default; -$global-large-box-shadow: 0 14px 25px rgba(0,0,0,0.16) !default; -$global-xlarge-box-shadow: 0 28px 50px rgba(0,0,0,0.16) !default; - -// -// Spacings -// - -// Used in margin, section, list -$global-margin: 20px !default; -$global-small-margin: 10px !default; -$global-medium-margin: 40px !default; -$global-large-margin: 70px !default; -$global-xlarge-margin: 140px !default; - -// Used in grid, column, container, align, card, padding -$global-gutter: 30px !default; -$global-small-gutter: 15px !default; -$global-medium-gutter: 40px !default; -$global-large-gutter: 70px !default; - -// -// Controls -// - -$global-control-height: 40px !default; -$global-control-small-height: 30px !default; -$global-control-large-height: 55px !default; - -// -// Z-index -// - -$global-z-index: 1000 !default; diff --git a/docs/_sass/uikit/components/visibility.scss b/docs/_sass/uikit/components/visibility.scss deleted file mode 100644 index 376af51639..0000000000 --- a/docs/_sass/uikit/components/visibility.scss +++ /dev/null @@ -1,175 +0,0 @@ -// Name: Visibility -// Description: Utilities to show or hide content on breakpoints, hover or touch -// -// Component: `uk-hidden-*` -// `uk-visible-*` -// `uk-invisible` -// `uk-visible-toggle` -// `uk-hidden-hover` -// `uk-invisible-hover` -// `uk-hidden-touch` -// `uk-hidden-notouch` -// -// ======================================================================== - - -/* ======================================================================== - Component: Visibility - ========================================================================== */ - -/* - * Hidden - * `hidden` attribute also set here to make it stronger - */ - -[hidden], -.uk-hidden { display: none !important; } - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-hidden\@s { display: none !important; } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-hidden\@m { display: none !important; } - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-hidden\@l { display: none !important; } - -} - -/* Large screen and bigger */ -@media (min-width: $breakpoint-xlarge) { - - .uk-hidden\@xl { display: none !important; } - -} - -/* - * Visible - */ - -/* Phone portrait and smaller */ -@media (max-width: $breakpoint-xsmall-max) { - - .uk-visible\@s { display: none !important; } - -} - -/* Phone landscape and smaller */ -@media (max-width: $breakpoint-small-max) { - - .uk-visible\@m { display: none !important; } - -} - -/* Tablet landscape and smaller */ -@media (max-width: $breakpoint-medium-max) { - - .uk-visible\@l { display: none !important; } - -} - -/* Desktop and smaller */ -@media (max-width: $breakpoint-large-max) { - - .uk-visible\@xl { display: none !important; } - -} - - -/* Visibility - ========================================================================== */ - -.uk-invisible { visibility: hidden !important; } - - -/* Toggle (Hover + Focus) - ========================================================================== */ - -/* - * Hidden - * 1. The toggle is triggered on touch devices using `:focus` and tabindex - * 2. The target stays visible if any element within receives focus through keyboard - * Doesn't work in Edge, yet. - * 3. Can't use `display: none` nor `visibility: hidden` because both are not focusable. - * - */ - -/* 1 + 2 */ -.uk-visible-toggle:not(:hover):not(:focus) .uk-hidden-hover:not(:focus-within) { - /* 3 */ - position: absolute !important; - width: 0 !important; - height: 0 !important; - padding: 0 !important; - margin: 0 !important; - overflow: hidden !important; -} - -/* - * Invisible - */ - -/* 1 + 2 */ -.uk-visible-toggle:not(:hover):not(:focus) .uk-invisible-hover:not(:focus-within) { - /* 3 */ - opacity: 0 !important; -} - -/* - * 1. Prevent tab highlighting on iOS. - */ - -.uk-visible-toggle { - /* 1 */ - -webkit-tap-highlight-color: transparent; -} - -/* - * Remove outline for `tabindex` - */ - -.uk-visible-toggle:focus { outline: none; } - - -/* Touch - ========================================================================== */ - -/* - * Hide if primary pointing device has limited accuracy, e.g. a touch screen. - * Works on mobile browsers: Safari, Chrome and Android browser - */ - -@media (pointer: coarse) { - .uk-hidden-touch { display: none !important; } -} - -/* - * Hide if primary pointing device is accurate, e.g. mouse. - * 1. Fallback for IE11 and Firefox, because `pointer` is not supported - * 2. Reset if supported - */ - -/* 1 */ -.uk-hidden-notouch { display: none !important; } - -@media (pointer: coarse) { - .uk-hidden-notouch { display: block !important; } -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-visibility-misc)) {@include hook-visibility-misc();} - -// @mixin hook-visibility-misc(){} diff --git a/docs/_sass/uikit/components/width.scss b/docs/_sass/uikit/components/width.scss deleted file mode 100644 index 30b873da00..0000000000 --- a/docs/_sass/uikit/components/width.scss +++ /dev/null @@ -1,379 +0,0 @@ -// Name: Width -// Description: Utilities for widths -// -// Component: `uk-child-width-*` -// `uk-width-*` -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$width-small-width: 150px !default; -$width-medium-width: 300px !default; -$width-large-width: 450px !default; -$width-xlarge-width: 600px !default; -$width-2xlarge-width: 750px !default; - - -/* ======================================================================== - Component: Width - ========================================================================== */ - - -/* Equal child widths - ========================================================================== */ - -[class*='uk-child-width'] > * { - box-sizing: border-box; - width: 100%; -} - -.uk-child-width-1-2 > * { width: 50%; } -.uk-child-width-1-3 > * { width: unquote('calc(100% * 1 / 3.001)'); } -.uk-child-width-1-4 > * { width: 25%; } -.uk-child-width-1-5 > * { width: 20%; } -.uk-child-width-1-6 > * { width: unquote('calc(100% * 1 / 6.001)'); } - -.uk-child-width-auto > * { width: auto; } - -/* - * 1. Reset the `min-width`, which is set to auto by default, because - * flex items won't shrink below their minimum intrinsic content size. - * Using `1px` instead of `0`, so items still wrap into the next line, - * if they have zero width and padding and the predecessor is 100% wide. - */ - -.uk-child-width-expand > :not([class*='uk-width']) { - flex: 1; - /* 1 */ - min-width: 1px; -} - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - .uk-child-width-1-1\@s > * { width: 100%; } - .uk-child-width-1-2\@s > * { width: 50%; } - .uk-child-width-1-3\@s > * { width: unquote('calc(100% * 1 / 3.001)'); } - .uk-child-width-1-4\@s > * { width: 25%; } - .uk-child-width-1-5\@s > * { width: 20%; } - .uk-child-width-1-6\@s > * { width: unquote('calc(100% * 1 / 6.001)'); } - - .uk-child-width-auto\@s > * { width: auto; } - .uk-child-width-expand\@s > :not([class*='uk-width']) { - flex: 1; - min-width: 1px; - } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - .uk-child-width-1-1\@m > * { width: 100%; } - .uk-child-width-1-2\@m > * { width: 50%; } - .uk-child-width-1-3\@m > * { width: unquote('calc(100% * 1 / 3.001)'); } - .uk-child-width-1-4\@m > * { width: 25%; } - .uk-child-width-1-5\@m > * { width: 20%; } - .uk-child-width-1-6\@m > * { width: unquote('calc(100% * 1 / 6.001)'); } - - .uk-child-width-auto\@m > * { width: auto; } - .uk-child-width-expand\@m > :not([class*='uk-width']) { - flex: 1; - min-width: 1px; - } - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - .uk-child-width-1-1\@l > * { width: 100%; } - .uk-child-width-1-2\@l > * { width: 50%; } - .uk-child-width-1-3\@l > * { width: unquote('calc(100% * 1 / 3.001)'); } - .uk-child-width-1-4\@l > * { width: 25%; } - .uk-child-width-1-5\@l > * { width: 20%; } - .uk-child-width-1-6\@l > * { width: unquote('calc(100% * 1 / 6.001)'); } - - .uk-child-width-auto\@l > * { width: auto; } - .uk-child-width-expand\@l > :not([class*='uk-width']) { - flex: 1; - min-width: 1px; - } - -} - -/* Large screen and bigger */ -@media (min-width: $breakpoint-xlarge) { - - .uk-child-width-1-1\@xl > * { width: 100%; } - .uk-child-width-1-2\@xl > * { width: 50%; } - .uk-child-width-1-3\@xl > * { width: unquote('calc(100% * 1 / 3.001)'); } - .uk-child-width-1-4\@xl > * { width: 25%; } - .uk-child-width-1-5\@xl > * { width: 20%; } - .uk-child-width-1-6\@xl > * { width: unquote('calc(100% * 1 / 6.001)'); } - - .uk-child-width-auto\@xl > * { width: auto; } - .uk-child-width-expand\@xl > :not([class*='uk-width']) { - flex: 1; - min-width: 1px; - } - -} - - -/* Single Widths - ========================================================================== */ - -/* - * 1. `max-width` is needed for the pixel-based classes - */ - -[class*='uk-width'] { - box-sizing: border-box; - width: 100%; - /* 1 */ - max-width: 100%; -} - -/* Halves */ -.uk-width-1-2 { width: 50%; } - -/* Thirds */ -.uk-width-1-3 { width: unquote('calc(100% * 1 / 3.001)'); } -.uk-width-2-3 { width: unquote('calc(100% * 2 / 3.001)'); } - -/* Quarters */ -.uk-width-1-4 { width: 25%; } -.uk-width-3-4 { width: 75%; } - -/* Fifths */ -.uk-width-1-5 { width: 20%; } -.uk-width-2-5 { width: 40%; } -.uk-width-3-5 { width: 60%; } -.uk-width-4-5 { width: 80%; } - -/* Sixths */ -.uk-width-1-6 { width: unquote('calc(100% * 1 / 6.001)'); } -.uk-width-5-6 { width: unquote('calc(100% * 5 / 6.001)'); } - -/* Pixel */ -.uk-width-small { width: $width-small-width; } -.uk-width-medium { width: $width-medium-width; } -.uk-width-large { width: $width-large-width; } -.uk-width-xlarge { width: $width-xlarge-width; } -.uk-width-2xlarge { width: $width-2xlarge-width; } -@if ($deprecated == true) { -.uk-width-xxlarge { width: $width-2xlarge-width; } -} - -/* Auto */ -.uk-width-auto { width: auto; } - -/* Expand */ -.uk-width-expand { - flex: 1; - min-width: 1px; -} - -/* Phone landscape and bigger */ -@media (min-width: $breakpoint-small) { - - /* Whole */ - .uk-width-1-1\@s { width: 100%; } - - /* Halves */ - .uk-width-1-2\@s { width: 50%; } - - /* Thirds */ - .uk-width-1-3\@s { width: unquote('calc(100% * 1 / 3.001)'); } - .uk-width-2-3\@s { width: unquote('calc(100% * 2 / 3.001)'); } - - /* Quarters */ - .uk-width-1-4\@s { width: 25%; } - .uk-width-3-4\@s { width: 75%; } - - /* Fifths */ - .uk-width-1-5\@s { width: 20%; } - .uk-width-2-5\@s { width: 40%; } - .uk-width-3-5\@s { width: 60%; } - .uk-width-4-5\@s { width: 80%; } - - /* Sixths */ - .uk-width-1-6\@s { width: unquote('calc(100% * 1 / 6.001)'); } - .uk-width-5-6\@s { width: unquote('calc(100% * 5 / 6.001)'); } - - /* Pixel */ - .uk-width-small\@s { width: $width-small-width; } - .uk-width-medium\@s { width: $width-medium-width; } - .uk-width-large\@s { width: $width-large-width; } - .uk-width-xlarge\@s { width: $width-xlarge-width; } - .uk-width-2xlarge\@s { width: $width-2xlarge-width; } - @if ($deprecated == true) { -.uk-width-xxlarge\@s { width: $width-2xlarge-width; } -} - - /* Auto */ - .uk-width-auto\@s { width: auto; } - - /* Expand */ - .uk-width-expand\@s { - flex: 1; - min-width: 1px; - } - -} - -/* Tablet landscape and bigger */ -@media (min-width: $breakpoint-medium) { - - /* Whole */ - .uk-width-1-1\@m { width: 100%; } - - /* Halves */ - .uk-width-1-2\@m { width: 50%; } - - /* Thirds */ - .uk-width-1-3\@m { width: unquote('calc(100% * 1 / 3.001)'); } - .uk-width-2-3\@m { width: unquote('calc(100% * 2 / 3.001)'); } - - /* Quarters */ - .uk-width-1-4\@m { width: 25%; } - .uk-width-3-4\@m { width: 75%; } - - /* Fifths */ - .uk-width-1-5\@m { width: 20%; } - .uk-width-2-5\@m { width: 40%; } - .uk-width-3-5\@m { width: 60%; } - .uk-width-4-5\@m { width: 80%; } - - /* Sixths */ - .uk-width-1-6\@m { width: unquote('calc(100% * 1 / 6.001)'); } - .uk-width-5-6\@m { width: unquote('calc(100% * 5 / 6.001)'); } - - /* Pixel */ - .uk-width-small\@m { width: $width-small-width; } - .uk-width-medium\@m { width: $width-medium-width; } - .uk-width-large\@m { width: $width-large-width; } - .uk-width-xlarge\@m { width: $width-xlarge-width; } - .uk-width-2xlarge\@m { width: $width-2xlarge-width; } - @if ($deprecated == true) { -.uk-width-xxlarge\@m { width: $width-2xlarge-width; } -} - - /* Auto */ - .uk-width-auto\@m { width: auto; } - - /* Expand */ - .uk-width-expand\@m { - flex: 1; - min-width: 1px; - } - -} - -/* Desktop and bigger */ -@media (min-width: $breakpoint-large) { - - /* Whole */ - .uk-width-1-1\@l { width: 100%; } - - /* Halves */ - .uk-width-1-2\@l { width: 50%; } - - /* Thirds */ - .uk-width-1-3\@l { width: unquote('calc(100% * 1 / 3.001)'); } - .uk-width-2-3\@l { width: unquote('calc(100% * 2 / 3.001)'); } - - /* Quarters */ - .uk-width-1-4\@l { width: 25%; } - .uk-width-3-4\@l { width: 75%; } - - /* Fifths */ - .uk-width-1-5\@l { width: 20%; } - .uk-width-2-5\@l { width: 40%; } - .uk-width-3-5\@l { width: 60%; } - .uk-width-4-5\@l { width: 80%; } - - /* Sixths */ - .uk-width-1-6\@l { width: unquote('calc(100% * 1 / 6.001)'); } - .uk-width-5-6\@l { width: unquote('calc(100% * 5 / 6.001)'); } - - /* Pixel */ - .uk-width-small\@l { width: $width-small-width; } - .uk-width-medium\@l { width: $width-medium-width; } - .uk-width-large\@l { width: $width-large-width; } - .uk-width-xlarge\@l { width: $width-xlarge-width; } - .uk-width-2xlarge\@l { width: $width-2xlarge-width; } - @if ($deprecated == true) { -.uk-width-xxlarge\@l { width: $width-2xlarge-width; } -} - - /* Auto */ - .uk-width-auto\@l { width: auto; } - - /* Expand */ - .uk-width-expand\@l { - flex: 1; - min-width: 1px; - } - -} - -/* Large screen and bigger */ -@media (min-width: $breakpoint-xlarge) { - - /* Whole */ - .uk-width-1-1\@xl { width: 100%; } - - /* Halves */ - .uk-width-1-2\@xl { width: 50%; } - - /* Thirds */ - .uk-width-1-3\@xl { width: unquote('calc(100% * 1 / 3.001)'); } - .uk-width-2-3\@xl { width: unquote('calc(100% * 2 / 3.001)'); } - - /* Quarters */ - .uk-width-1-4\@xl { width: 25%; } - .uk-width-3-4\@xl { width: 75%; } - - /* Fifths */ - .uk-width-1-5\@xl { width: 20%; } - .uk-width-2-5\@xl { width: 40%; } - .uk-width-3-5\@xl { width: 60%; } - .uk-width-4-5\@xl { width: 80%; } - - /* Sixths */ - .uk-width-1-6\@xl { width: unquote('calc(100% * 1 / 6.001)'); } - .uk-width-5-6\@xl { width: unquote('calc(100% * 5 / 6.001)'); } - - /* Pixel */ - .uk-width-small\@xl { width: $width-small-width; } - .uk-width-medium\@xl { width: $width-medium-width; } - .uk-width-large\@xl { width: $width-large-width; } - .uk-width-xlarge\@xl { width: $width-xlarge-width; } - .uk-width-2xlarge\@xl { width: $width-2xlarge-width; } - @if ($deprecated == true) { -.uk-width-xxlarge\@xl { width: $width-2xlarge-width; } -} - - /* Auto */ - .uk-width-auto\@xl { width: auto; } - - /* Expand */ - .uk-width-expand\@xl { - flex: 1; - min-width: 1px; - } - -} - - -// Hooks -// ======================================================================== - -@if(mixin-exists(hook-width-misc)) {@include hook-width-misc();} - -// @mixin hook-width-misc(){} diff --git a/docs/_sass/uikit/mixins-theme.scss b/docs/_sass/uikit/mixins-theme.scss deleted file mode 100644 index 59826ca64f..0000000000 --- a/docs/_sass/uikit/mixins-theme.scss +++ /dev/null @@ -1,2204 +0,0 @@ -@mixin hook-accordion(){} -@mixin hook-accordion-item(){} -@mixin hook-accordion-title(){ - - overflow: hidden; - - &::before { - content: ""; - width: ($accordion-title-line-height * 1em); - height: ($accordion-title-line-height * 1em); - margin-left: $accordion-icon-margin-left; - float: right; - @include svg-fill($internal-accordion-close-image, "#000", $accordion-icon-color); - background-repeat: no-repeat; - background-position: 50% 50%; - } - - .uk-open > &::before { @include svg-fill($internal-accordion-open-image, "#000", $accordion-icon-color); } - -} -@mixin hook-accordion-title-hover(){} -@mixin hook-accordion-content(){} -@mixin hook-accordion-misc(){} -@mixin hook-inverse-accordion-item(){} -@mixin hook-inverse-accordion-title(){} -@mixin hook-inverse-accordion-title-hover(){} -@mixin hook-inverse-component-accordion(){ - - .uk-accordion-title::before { @include svg-fill($internal-accordion-close-image, "#000", $inverse-global-color); } - - .uk-open > .uk-accordion-title::before { @include svg-fill($internal-accordion-open-image, "#000", $inverse-global-color); } - -} -@mixin hook-alert(){} -@mixin hook-alert-close(){ - color: inherit; - opacity: $alert-close-opacity; -} -@mixin hook-alert-close-hover(){ - color: inherit; - opacity: $alert-close-hover-opacity; -} -@mixin hook-alert-primary(){} -@mixin hook-alert-success(){} -@mixin hook-alert-warning(){} -@mixin hook-alert-danger(){} -@mixin hook-alert-misc(){ - - /* - * Content - */ - - .uk-alert h1, - .uk-alert h2, - .uk-alert h3, - .uk-alert h4, - .uk-alert h5, - .uk-alert h6 { color: inherit; } - - .uk-alert a:not([class]) { - color: inherit; - text-decoration: underline; - } - - .uk-alert a:not([class]):hover { - color: inherit; - text-decoration: underline; - } - -} -@mixin hook-align-misc(){} -@mixin hook-animation-misc(){} -@mixin hook-article(){} -@mixin hook-article-adjacent(){} -@mixin hook-article-title(){} -@mixin hook-article-meta(){ - - a { color: $article-meta-link-color; } - - a:hover { - color: $article-meta-link-hover-color; - text-decoration: none; - } - -} -@mixin hook-article-misc(){} -@mixin hook-inverse-article-title(){} -@mixin hook-inverse-article-meta(){} -@mixin hook-inverse-component-article(){ - - .uk-article-title { - @if(mixin-exists(hook-inverse-article-title)) {@include hook-inverse-article-title();} - } - - .uk-article-meta { - color: $inverse-article-meta-color; - @if(mixin-exists(hook-inverse-article-meta)) {@include hook-inverse-article-meta();} - } - -} -@mixin hook-background-misc(){} -@mixin hook-badge(){} -@mixin hook-badge-hover(){} -@mixin hook-badge-misc(){} -@mixin hook-inverse-badge(){} -@mixin hook-inverse-badge-hover(){} -@mixin hook-inverse-component-badge(){ - - .uk-badge { - background-color: $inverse-badge-background; - color: $inverse-badge-color !important; - @if(mixin-exists(hook-inverse-badge)) {@include hook-inverse-badge();} - } - - .uk-badge:hover, - .uk-badge:focus { - @if(mixin-exists(hook-inverse-badge-hover)) {@include hook-inverse-badge-hover();} - } - -} -@mixin hook-base-body(){} -@mixin hook-base-link(){} -@mixin hook-base-link-hover(){} -@mixin hook-base-code(){ - padding: $base-code-padding-vertical $base-code-padding-horizontal; - background: $base-code-background; -} -@mixin hook-base-heading(){} -@mixin hook-base-h1(){} -@mixin hook-base-h2(){} -@mixin hook-base-h3(){} -@mixin hook-base-h4(){} -@mixin hook-base-h5(){} -@mixin hook-base-h6(){} -@mixin hook-base-hr(){} -@mixin hook-base-blockquote(){ - color: $base-blockquote-color; -} -@mixin hook-base-blockquote-footer(){ - - color: $base-blockquote-footer-color; - - &::before { content: "— "; } - -} -@mixin hook-base-pre(){ - padding: $base-pre-padding; - border: $base-pre-border-width solid $base-pre-border; - border-radius: $base-pre-border-radius; - background: $base-pre-background; -} -@mixin hook-base-misc(){} -@mixin hook-inverse-base-link(){} -@mixin hook-inverse-base-link-hover(){} -@mixin hook-inverse-base-code(){ - background: $inverse-global-muted-background; -} -@mixin hook-inverse-base-heading(){} -@mixin hook-inverse-base-h1(){} -@mixin hook-inverse-base-h2(){} -@mixin hook-inverse-base-h3(){} -@mixin hook-inverse-base-h4(){} -@mixin hook-inverse-base-h5(){} -@mixin hook-inverse-base-h6(){} -@mixin hook-inverse-base-blockquote(){ color: $inverse-base-blockquote-color; } -@mixin hook-inverse-base-blockquote-footer(){ color: $inverse-base-blockquote-footer-color; } -@mixin hook-inverse-base-hr(){} -@mixin hook-inverse-component-base(){ - - color: $inverse-base-color; - - // Base - // ======================================================================== - - // - // Link - // - - a, - .uk-link { - color: $inverse-base-link-color; - @if(mixin-exists(hook-inverse-base-link)) {@include hook-inverse-base-link();} - } - - a:hover, - .uk-link:hover, - .uk-link-toggle:hover .uk-link, - .uk-link-toggle:focus .uk-link { - color: $inverse-base-link-hover-color; - @if(mixin-exists(hook-inverse-base-link-hover)) {@include hook-inverse-base-link-hover();} - } - - // - // Code - // - - :not(pre) > code, - :not(pre) > kbd, - :not(pre) > samp { - color: $inverse-base-code-color; - @if(mixin-exists(hook-inverse-base-code)) {@include hook-inverse-base-code();} - } - - // - // Emphasize - // - - em { color: $inverse-base-em-color; } - - // - // Headings - // - - h1, .uk-h1, - h2, .uk-h2, - h3, .uk-h3, - h4, .uk-h4, - h5, .uk-h5, - h6, .uk-h6, - .uk-heading-small, - .uk-heading-medium, - .uk-heading-large, - .uk-heading-xlarge, - .uk-heading-2xlarge { - color: $inverse-base-heading-color; - @if(mixin-exists(hook-inverse-base-heading)) {@include hook-inverse-base-heading();} - } - - h1, .uk-h1 { - @if(mixin-exists(hook-inverse-base-h1)) {@include hook-inverse-base-h1();} - } - - h2, .uk-h2 { - @if(mixin-exists(hook-inverse-base-h2)) {@include hook-inverse-base-h2();} - } - - h3, .uk-h3 { - @if(mixin-exists(hook-inverse-base-h3)) {@include hook-inverse-base-h3();} - } - - h4, .uk-h4 { - @if(mixin-exists(hook-inverse-base-h4)) {@include hook-inverse-base-h4();} - } - - h5, .uk-h5 { - @if(mixin-exists(hook-inverse-base-h5)) {@include hook-inverse-base-h5();} - } - - h6, .uk-h6 { - @if(mixin-exists(hook-inverse-base-h6)) {@include hook-inverse-base-h6();} - } - - // - // Blockquotes - // - - blockquote { - @if(mixin-exists(hook-inverse-base-blockquote)) {@include hook-inverse-base-blockquote();} - } - - blockquote footer { - @if(mixin-exists(hook-inverse-base-blockquote-footer)) {@include hook-inverse-base-blockquote-footer();} - } - - // - // Horizontal rules - // - - hr, .uk-hr { - border-top-color: $inverse-base-hr-border; - @if(mixin-exists(hook-inverse-base-hr)) {@include hook-inverse-base-hr();} - } - -} -@mixin hook-breadcrumb(){} -@mixin hook-breadcrumb-item(){} -@mixin hook-breadcrumb-item-hover(){} -@mixin hook-breadcrumb-item-disabled(){} -@mixin hook-breadcrumb-item-active(){} -@mixin hook-breadcrumb-divider(){} -@mixin hook-breadcrumb-misc(){} -@mixin hook-inverse-breadcrumb-item(){} -@mixin hook-inverse-breadcrumb-item-hover(){} -@mixin hook-inverse-breadcrumb-item-disabled(){} -@mixin hook-inverse-breadcrumb-item-active(){} -@mixin hook-inverse-breadcrumb-divider(){} -@mixin hook-inverse-component-breadcrumb(){ - - .uk-breadcrumb > * > * { - color: $inverse-breadcrumb-item-color; - @if(mixin-exists(hook-inverse-breadcrumb-item)) {@include hook-inverse-breadcrumb-item();} - } - - .uk-breadcrumb > * > :hover, - .uk-breadcrumb > * > :focus { - color: $inverse-breadcrumb-item-hover-color; - @if(mixin-exists(hook-inverse-breadcrumb-item-hover)) {@include hook-inverse-breadcrumb-item-hover();} - } - - - .uk-breadcrumb > .uk-disabled > * { - @if(mixin-exists(hook-inverse-breadcrumb-item-disabled)) {@include hook-inverse-breadcrumb-item-disabled();} - } - - .uk-breadcrumb > :last-child > * { - color: $inverse-breadcrumb-item-active-color; - @if(mixin-exists(hook-inverse-breadcrumb-item-active)) {@include hook-inverse-breadcrumb-item-active();} - } - - // - // Divider - // - - .uk-breadcrumb > :nth-child(n+2):not(.uk-first-column)::before { - color: $inverse-breadcrumb-divider-color; - @if(mixin-exists(hook-inverse-breadcrumb-divider)) {@include hook-inverse-breadcrumb-divider();} - } - -} -@mixin hook-button(){ - text-transform: $button-text-transform; - transition: 0.1s ease-in-out; - transition-property: color, background-color, border-color; -} -@mixin hook-button-hover(){} -@mixin hook-button-focus(){} -@mixin hook-button-active(){} -@mixin hook-button-default(){ border: $button-border-width solid $button-default-border; } -@mixin hook-button-default-hover(){ border-color: $button-default-hover-border; } -@mixin hook-button-default-active(){ border-color: $button-default-active-border; } -@mixin hook-button-primary(){ border: $button-border-width solid transparent; } -@mixin hook-button-primary-hover(){} -@mixin hook-button-primary-active(){} -@mixin hook-button-secondary(){ border: $button-border-width solid transparent; } -@mixin hook-button-secondary-hover(){} -@mixin hook-button-secondary-active(){} -@mixin hook-button-danger(){ border: $button-border-width solid transparent; } -@mixin hook-button-danger-hover(){} -@mixin hook-button-danger-active(){} -@mixin hook-button-disabled(){ border-color: $button-disabled-border; } -@mixin hook-button-small(){} -@mixin hook-button-large(){} -@mixin hook-button-text(){ - - position: relative; - - &::before { - content: ""; - position: absolute; - bottom: 0; - left: 0; - right: 100%; - border-bottom: $button-text-border-width solid $button-text-border; - transition: right 0.3s ease-out; - } - -} -@mixin hook-button-text-hover(){ - - &::before { right: 0; } - -} -@mixin hook-button-text-disabled(){ - - &::before { display: none; } - -} -@mixin hook-button-link(){} -@mixin hook-button-misc(){ - - /* Group - ========================================================================== */ - - /* - * Collapse border - */ - - .uk-button-group > .uk-button:nth-child(n+2), - .uk-button-group > div:nth-child(n+2) .uk-button { margin-left: (-$button-border-width); } - - /* - * Create position context to superimpose the successor elements border - * Known issue: If you use an `a` element as button and an icon inside, - * the active state will not work if you click the icon inside the button - * Workaround: Just use a `button` or `input` element as button - */ - - .uk-button-group .uk-button:hover, - .uk-button-group .uk-button:focus, - .uk-button-group .uk-button:active, - .uk-button-group .uk-button.uk-active { - position: relative; - z-index: 1; - } - -} -@mixin hook-inverse-button-default(){ border-color: $inverse-global-color; } -@mixin hook-inverse-button-default-hover(){ border-color: $inverse-global-emphasis-color; } -@mixin hook-inverse-button-default-active(){ border-color: $inverse-global-emphasis-color; } -@mixin hook-inverse-button-primary(){} -@mixin hook-inverse-button-primary-hover(){} -@mixin hook-inverse-button-primary-active(){} -@mixin hook-inverse-button-secondary(){} -@mixin hook-inverse-button-secondary-hover(){} -@mixin hook-inverse-button-secondary-active(){} -@mixin hook-inverse-button-text(){ - &::before { border-bottom-color: $inverse-global-emphasis-color; } -} -@mixin hook-inverse-button-text-hover(){} -@mixin hook-inverse-button-text-disabled(){} -@mixin hook-inverse-button-link(){} -@mixin hook-inverse-component-button(){ - - // - // Default - // - - .uk-button-default { - background-color: $inverse-button-default-background; - color: $inverse-button-default-color; - @if(mixin-exists(hook-inverse-button-default)) {@include hook-inverse-button-default();} - } - - .uk-button-default:hover, - .uk-button-default:focus { - background-color: $inverse-button-default-hover-background; - color: $inverse-button-default-hover-color; - @if(mixin-exists(hook-inverse-button-default-hover)) {@include hook-inverse-button-default-hover();} - } - - .uk-button-default:active, - .uk-button-default.uk-active { - background-color: $inverse-button-default-active-background; - color: $inverse-button-default-active-color; - @if(mixin-exists(hook-inverse-button-default-active)) {@include hook-inverse-button-default-active();} - } - - // - // Primary - // - - .uk-button-primary { - background-color: $inverse-button-primary-background; - color: $inverse-button-primary-color; - @if(mixin-exists(hook-inverse-button-primary)) {@include hook-inverse-button-primary();} - } - - .uk-button-primary:hover, - .uk-button-primary:focus { - background-color: $inverse-button-primary-hover-background; - color: $inverse-button-primary-hover-color; - @if(mixin-exists(hook-inverse-button-primary-hover)) {@include hook-inverse-button-primary-hover();} - } - - .uk-button-primary:active, - .uk-button-primary.uk-active { - background-color: $inverse-button-primary-active-background; - color: $inverse-button-primary-active-color; - @if(mixin-exists(hook-inverse-button-primary-active)) {@include hook-inverse-button-primary-active();} - } - - // - // Secondary - // - - .uk-button-secondary { - background-color: $inverse-button-secondary-background; - color: $inverse-button-secondary-color; - @if(mixin-exists(hook-inverse-button-secondary)) {@include hook-inverse-button-secondary();} - } - - .uk-button-secondary:hover, - .uk-button-secondary:focus { - background-color: $inverse-button-secondary-hover-background; - color: $inverse-button-secondary-hover-color; - @if(mixin-exists(hook-inverse-button-secondary-hover)) {@include hook-inverse-button-secondary-hover();} - } - - .uk-button-secondary:active, - .uk-button-secondary.uk-active { - background-color: $inverse-button-secondary-active-background; - color: $inverse-button-secondary-active-color; - @if(mixin-exists(hook-inverse-button-secondary-active)) {@include hook-inverse-button-secondary-active();} - } - - // - // Text - // - - .uk-button-text { - color: $inverse-button-text-color; - @if(mixin-exists(hook-inverse-button-text)) {@include hook-inverse-button-text();} - } - - .uk-button-text:hover, - .uk-button-text:focus { - color: $inverse-button-text-hover-color; - @if(mixin-exists(hook-inverse-button-text-hover)) {@include hook-inverse-button-text-hover();} - } - - .uk-button-text:disabled { - color: $inverse-button-text-disabled-color; - @if(mixin-exists(hook-inverse-button-text-disabled)) {@include hook-inverse-button-text-disabled();} - } - - // - // Link - // - - .uk-button-link { - color: $inverse-button-link-color; - @if(mixin-exists(hook-inverse-button-link)) {@include hook-inverse-button-link();} - } - - .uk-button-link:hover, - .uk-button-link:focus { color: $inverse-button-link-hover-color; } - - -} -@mixin hook-card(){ transition: box-shadow 0.1s ease-in-out; } -@mixin hook-card-body(){} -@mixin hook-card-header(){} -@mixin hook-card-footer(){} -@mixin hook-card-media(){} -@mixin hook-card-media-top(){} -@mixin hook-card-media-bottom(){} -@mixin hook-card-media-left(){} -@mixin hook-card-media-right(){} -@mixin hook-card-title(){} -@mixin hook-card-badge(){ - border-radius: $card-badge-border-radius; - text-transform: $card-badge-text-transform; -} -@mixin hook-card-hover(){ box-shadow: $card-hover-box-shadow; } -@mixin hook-card-default(){ box-shadow: $card-default-box-shadow; } -@mixin hook-card-default-title(){} -@mixin hook-card-default-hover(){ box-shadow: $card-default-hover-box-shadow; } -@mixin hook-card-default-header(){ border-bottom: $card-default-header-border-width solid $card-default-header-border; } -@mixin hook-card-default-footer(){ border-top: $card-default-footer-border-width solid $card-default-footer-border; } -@mixin hook-card-primary(){ box-shadow: $card-primary-box-shadow; } -@mixin hook-card-primary-title(){} -@mixin hook-card-primary-hover(){ box-shadow: $card-primary-hover-box-shadow; } -@mixin hook-card-secondary(){ box-shadow: $card-secondary-box-shadow; } -@mixin hook-card-secondary-title(){} -@mixin hook-card-secondary-hover(){ box-shadow: $card-secondary-hover-box-shadow; } -@mixin hook-card-misc(){ - - /* - * Default - */ - - .uk-card-body > .uk-nav-default { - margin-left: (-$card-body-padding-horizontal); - margin-right: (-$card-body-padding-horizontal); - } - .uk-card-body > .uk-nav-default:only-child { - margin-top: (-$card-body-padding-vertical + 15px); - margin-bottom: (-$card-body-padding-vertical + 15px); - } - - .uk-card-body > .uk-nav-default > li > a, - .uk-card-body > .uk-nav-default .uk-nav-header, - .uk-card-body > .uk-nav-default .uk-nav-divider { - padding-left: $card-body-padding-horizontal; - padding-right: $card-body-padding-horizontal; - } - - .uk-card-body > .uk-nav-default .uk-nav-sub { padding-left: $nav-sublist-deeper-padding-left + $card-body-padding-horizontal; } - - - /* Desktop and bigger */ - @media (min-width: $breakpoint-large) { - - .uk-card-body > .uk-nav-default { - margin-left: (-$card-body-padding-horizontal-l); - margin-right: (-$card-body-padding-horizontal-l); - } - .uk-card-body > .uk-nav-default:only-child { - margin-top: (-$card-body-padding-vertical-l + 15px); - margin-bottom: (-$card-body-padding-vertical-l + 15px); - } - - .uk-card-body > .uk-nav-default > li > a, - .uk-card-body > .uk-nav-default .uk-nav-header, - .uk-card-body > .uk-nav-default .uk-nav-divider { - padding-left: $card-body-padding-horizontal-l; - padding-right: $card-body-padding-horizontal-l; - } - - .uk-card-body > .uk-nav-default .uk-nav-sub { padding-left: $nav-sublist-deeper-padding-left + $card-body-padding-horizontal-l; } - - } - - /* - * Small - */ - - .uk-card-small > .uk-nav-default { - margin-left: (-$card-small-body-padding-horizontal); - margin-right: (-$card-small-body-padding-horizontal); - } - .uk-card-small > .uk-nav-default:only-child { - margin-top: (-$card-small-body-padding-vertical + 15px); - margin-bottom: (-$card-small-body-padding-vertical + 15px); - } - - .uk-card-small > .uk-nav-default > li > a, - .uk-card-small > .uk-nav-default .uk-nav-header, - .uk-card-small > .uk-nav-default .uk-nav-divider { - padding-left: $card-small-body-padding-horizontal; - padding-right: $card-small-body-padding-horizontal; - } - - .uk-card-small > .uk-nav-default .uk-nav-sub { padding-left: $nav-sublist-deeper-padding-left + $card-small-body-padding-horizontal; } - - /* - * Large - */ - - /* Desktop and bigger */ - @media (min-width: $breakpoint-large) { - - .uk-card-large > .uk-nav-default { margin: 0; } - .uk-card-large > .uk-nav-default:only-child { margin: 0; } - - .uk-card-large > .uk-nav-default > li > a, - .uk-card-large > .uk-nav-default .uk-nav-header, - .uk-card-large > .uk-nav-default .uk-nav-divider { - padding-left: 0; - padding-right: 0; - } - - .uk-card-large > .uk-nav-default .uk-nav-sub { padding-left: $nav-sublist-deeper-padding-left; } - - } - -} -@mixin hook-inverse-card-badge(){} -@mixin hook-inverse-component-card(){ - - &.uk-card-badge { - background-color: $inverse-card-badge-background; - color: $inverse-card-badge-color; - @if(mixin-exists(hook-inverse-card-badge)) {@include hook-inverse-card-badge();} - } - -} -@mixin hook-close(){ - transition: 0.1s ease-in-out; - transition-property: color, opacity; -} -@mixin hook-close-hover(){} -@mixin hook-close-misc(){} -@mixin hook-inverse-close(){} -@mixin hook-inverse-close-hover(){} -@mixin hook-inverse-component-close(){ - - .uk-close { - color: $inverse-close-color; - @if(mixin-exists(hook-inverse-close)) {@include hook-inverse-close();} - } - - .uk-close:hover, - .uk-close:focus { - color: $inverse-close-hover-color; - @if(mixin-exists(hook-inverse-close-hover)) {@include hook-inverse-close-hover();} - } - -} -@mixin hook-column-misc(){} -@mixin hook-inverse-component-column(){ - - .uk-column-divider { column-rule-color: $inverse-column-divider-rule-color; } - -} -@mixin hook-comment(){} -@mixin hook-comment-body(){} -@mixin hook-comment-header(){} -@mixin hook-comment-title(){} -@mixin hook-comment-meta(){} -@mixin hook-comment-avatar(){} -@mixin hook-comment-list-adjacent(){} -@mixin hook-comment-list-sub(){} -@mixin hook-comment-list-sub-adjacent(){} -@mixin hook-comment-primary(){ - padding: $comment-primary-padding; - background-color: $comment-primary-background; -} -@mixin hook-comment-misc(){} -@mixin hook-container-misc(){} -@mixin hook-countdown(){} -@mixin hook-countdown-item(){} -@mixin hook-countdown-number(){} -@mixin hook-countdown-separator(){} -@mixin hook-countdown-label(){} -@mixin hook-countdown-misc(){} -@mixin hook-inverse-countdown-item(){} -@mixin hook-inverse-countdown-number(){} -@mixin hook-inverse-countdown-separator(){} -@mixin hook-inverse-countdown-label(){} -@mixin hook-inverse-component-countdown(){ - - .uk-countdown-number, - .uk-countdown-separator { - @if(mixin-exists(hook-inverse-countdown-item)) {@include hook-inverse-countdown-item();} - } - - .uk-countdown-number { - @if(mixin-exists(hook-inverse-countdown-number)) {@include hook-inverse-countdown-number();} - } - - .uk-countdown-separator { - @if(mixin-exists(hook-inverse-countdown-separator)) {@include hook-inverse-countdown-separator();} - } - - .uk-countdown-label { - @if(mixin-exists(hook-inverse-countdown-label)) {@include hook-inverse-countdown-label();} - } - -} -@mixin hook-cover-misc(){} -@mixin hook-description-list-term(){ - font-size: $description-list-term-font-size; - font-weight: $description-list-term-font-weight; - text-transform: $description-list-term-text-transform; -} -@mixin hook-description-list-description(){} -@mixin hook-description-list-divider-term(){} -@mixin hook-description-list-misc(){} -@mixin svg-fill($src, $color-default, $color-new, $property: background-image){ - - $escape-color-default: escape($color-default) !default; - $escape-color-new: escape("#{$color-new}") !default; - - $data-uri: data-uri('image/svg+xml;charset=UTF-8', "#{$src}") !default; - $replace-src: replace("#{$data-uri}", "#{$escape-color-default}", "#{$escape-color-new}", "g") !default; - - #{$property}: unquote($replace-src); -} -@mixin hook-divider-icon(){} -@mixin hook-divider-icon-line(){} -@mixin hook-divider-icon-line-left(){} -@mixin hook-divider-icon-line-right(){} -@mixin hook-divider-small(){} -@mixin hook-divider-vertical(){} -@mixin hook-divider-misc(){} -@mixin hook-inverse-divider-icon(){} -@mixin hook-inverse-divider-icon-line(){} -@mixin hook-inverse-divider-small(){} -@mixin hook-inverse-divider-vertical(){} -@mixin hook-inverse-component-divider(){ - - .uk-divider-icon { - @include svg-fill($internal-divider-icon-image, "#000", $inverse-divider-icon-color); - @if(mixin-exists(hook-inverse-divider-icon)) {@include hook-inverse-divider-icon();} - } - - .uk-divider-icon::before, - .uk-divider-icon::after { - border-bottom-color: $inverse-divider-icon-line-border; - @if(mixin-exists(hook-inverse-divider-icon-line)) {@include hook-inverse-divider-icon-line();} - } - - .uk-divider-small::after { - border-top-color: $inverse-divider-small-border; - @if(mixin-exists(hook-inverse-divider-small)) {@include hook-inverse-divider-small();} - } - - .uk-divider-vertical { - border-left-color: $inverse-divider-vertical-border; - @if(mixin-exists(hook-inverse-divider-vertical)) {@include hook-inverse-divider-vertical();} - } - -} -@mixin hook-dotnav(){} -@mixin hook-dotnav-item(){ - border: $dotnav-item-border-width solid $dotnav-item-border; - transition: 0.2s ease-in-out; - transition-property: background-color, border-color; -} -@mixin hook-dotnav-item-hover(){ border-color: $dotnav-item-hover-border; } -@mixin hook-dotnav-item-onclick(){ border-color: $dotnav-item-onclick-border; } -@mixin hook-dotnav-item-active(){ border-color: $dotnav-item-active-border; } -@mixin hook-dotnav-misc(){} -@mixin hook-inverse-dotnav-item(){ border-color: rgba($inverse-global-color, 0.9); } -@mixin hook-inverse-dotnav-item-hover(){ border-color: transparent; } -@mixin hook-inverse-dotnav-item-onclick(){ border-color: transparent; } -@mixin hook-inverse-dotnav-item-active(){ border-color: transparent; } -@mixin hook-inverse-component-dotnav(){ - - .uk-dotnav > * > * { - background-color: $inverse-dotnav-item-background; - @if(mixin-exists(hook-inverse-dotnav-item)) {@include hook-inverse-dotnav-item();} - } - - .uk-dotnav > * > :hover, - .uk-dotnav > * > :focus { - background-color: $inverse-dotnav-item-hover-background; - @if(mixin-exists(hook-inverse-dotnav-item-hover)) {@include hook-inverse-dotnav-item-hover();} - } - - .uk-dotnav > * > :active { - background-color: $inverse-dotnav-item-onclick-background; - @if(mixin-exists(hook-inverse-dotnav-item-onclick)) {@include hook-inverse-dotnav-item-onclick();} - } - - .uk-dotnav > .uk-active > * { - background-color: $inverse-dotnav-item-active-background; - @if(mixin-exists(hook-inverse-dotnav-item-active)) {@include hook-inverse-dotnav-item-active();} - } - -} -@mixin hook-drop-misc(){} -@mixin hook-dropdown(){ box-shadow: $dropdown-box-shadow; } -@mixin hook-dropdown-nav(){ font-size: $dropdown-nav-font-size; } -@mixin hook-dropdown-nav-item(){} -@mixin hook-dropdown-nav-item-hover(){} -@mixin hook-dropdown-nav-header(){} -@mixin hook-dropdown-nav-divider(){} -@mixin hook-dropdown-misc(){} -@mixin hook-flex-misc(){} -@mixin hook-form-range(){} -@mixin hook-form-range-thumb(){ border: $form-range-thumb-border-width solid $form-range-thumb-border; } -@mixin hook-form-range-track(){ border-radius: $form-range-track-border-radius; } -@mixin hook-form-range-track-focus(){} -@mixin hook-form-range-misc(){} -@mixin hook-form(){ - border: $form-border-width solid $form-border; - transition: 0.2s ease-in-out; - transition-property: color, background-color, border; -} -@mixin hook-form-single-line(){} -@mixin hook-form-multi-line(){} -@mixin hook-form-focus(){ border-color: $form-focus-border; } -@mixin hook-form-disabled(){ border-color: $form-disabled-border; } -@mixin hook-form-danger(){ border-color: $form-danger-border; } -@mixin hook-form-success(){ border-color: $form-success-border; } -@mixin hook-form-blank(){ border-color: transparent; } -@mixin hook-form-blank-focus(){ - border-color: $form-blank-focus-border; - border-style: $form-blank-focus-border-style; -} -@mixin hook-form-radio(){ - border: $form-radio-border-width solid $form-radio-border; - transition: 0.2s ease-in-out; - transition-property: background-color, border; -} -@mixin hook-form-radio-focus(){ border-color: $form-radio-focus-border; } -@mixin hook-form-radio-checked(){ border-color: $form-radio-checked-border; } -@mixin hook-form-radio-checked-focus(){} -@mixin hook-form-radio-disabled(){ border-color: $form-radio-disabled-border; } -@mixin hook-form-legend(){} -@mixin hook-form-label(){ - color: $form-label-color; - font-size: $form-label-font-size; -} -@mixin hook-form-stacked-label(){} -@mixin hook-form-horizontal-label(){} -@mixin hook-form-misc(){} -@mixin hook-inverse-form(){ border-color: $inverse-global-border; } -@mixin hook-inverse-form-focus(){ border-color: $inverse-global-color; } -@mixin hook-inverse-form-radio(){ border-color: $inverse-global-border; } -@mixin hook-inverse-form-radio-focus(){ border-color: $inverse-global-color; } -@mixin hook-inverse-form-radio-checked(){ border-color: $inverse-global-color; } -@mixin hook-inverse-form-radio-checked-focus(){} -@mixin hook-inverse-form-label(){ color: $inverse-form-label-color; } -@mixin hook-inverse-component-form(){ - - .uk-input, - .uk-select, - .uk-textarea { - background-color: $inverse-form-background; - color: $inverse-form-color; - background-clip: padding-box; - @if(mixin-exists(hook-inverse-form)) {@include hook-inverse-form();} - - &:focus { - background-color: $inverse-form-focus-background; - color: $inverse-form-focus-color; - @if(mixin-exists(hook-inverse-form-focus)) {@include hook-inverse-form-focus();} - } - } - - // - // Placeholder - // - - .uk-input::-ms-input-placeholder { color: $inverse-form-placeholder-color !important; } - .uk-input::placeholder { color: $inverse-form-placeholder-color; } - - .uk-textarea::-ms-input-placeholder { color: $inverse-form-placeholder-color !important; } - .uk-textarea::placeholder { color: $inverse-form-placeholder-color; } - - // - // Select - // - - .uk-select:not([multiple]):not([size]) { @include svg-fill($internal-form-select-image, "#000", $inverse-form-select-icon-color); } - - // - // Datalist - // - - .uk-input[list]:hover, - .uk-input[list]:focus { @include svg-fill($internal-form-datalist-image, "#000", $inverse-form-datalist-icon-color); } - - // - // Radio and checkbox - // - - .uk-radio, - .uk-checkbox { - background-color: $inverse-form-radio-background; - @if(mixin-exists(hook-inverse-form-radio)) {@include hook-inverse-form-radio();} - } - - // Focus - .uk-radio:focus, - .uk-checkbox:focus { - background-color: $inverse-form-radio-focus-background; - @if(mixin-exists(hook-inverse-form-radio-focus)) {@include hook-inverse-form-radio-focus();} - } - - // Checked - .uk-radio:checked, - .uk-checkbox:checked, - .uk-checkbox:indeterminate { - background-color: $inverse-form-radio-checked-background; - @if(mixin-exists(hook-inverse-form-radio-checked)) {@include hook-inverse-form-radio-checked();} - } - - // Focus - .uk-radio:checked:focus, - .uk-checkbox:checked:focus, - .uk-checkbox:indeterminate:focus { - background-color: $inverse-form-radio-checked-focus-background; - @if(mixin-exists(hook-inverse-form-radio-checked-focus)) {@include hook-inverse-form-radio-checked-focus();} - } - - // Icon - .uk-radio:checked { @include svg-fill($internal-form-radio-image, "#000", $inverse-form-radio-checked-icon-color); } - .uk-checkbox:checked { @include svg-fill($internal-form-checkbox-image, "#000", $inverse-form-radio-checked-icon-color); } - .uk-checkbox:indeterminate { @include svg-fill($internal-form-checkbox-indeterminate-image, "#000", $inverse-form-radio-checked-icon-color); } - - // Label - .uk-form-label { - @if(mixin-exists(hook-inverse-form-label)) {@include hook-inverse-form-label();} - } - - // Icon - .uk-form-icon { color: $inverse-form-icon-color; } - .uk-form-icon:hover { color: $inverse-form-icon-hover-color; } - -} -@mixin hook-grid-divider-horizontal(){} -@mixin hook-grid-divider-vertical(){} -@mixin hook-grid-misc(){} -@mixin hook-inverse-grid-divider-horizontal(){} -@mixin hook-inverse-grid-divider-vertical(){} -@mixin hook-inverse-component-grid(){ - - .uk-grid-divider > :not(.uk-first-column)::before { - border-left-color: $inverse-grid-divider-border; - @if(mixin-exists(hook-inverse-grid-divider-horizontal)) {@include hook-inverse-grid-divider-horizontal();} - } - - .uk-grid-divider.uk-grid-stack > .uk-grid-margin::before { - border-top-color: $inverse-grid-divider-border; - @if(mixin-exists(hook-inverse-grid-divider-vertical)) {@include hook-inverse-grid-divider-vertical();} - } - -} -@mixin hook-heading-small(){} -@mixin hook-heading-medium(){} -@mixin hook-heading-large(){} -@mixin hook-heading-xlarge(){} -@mixin hook-heading-2xlarge(){} -@mixin hook-heading-primary(){} -@mixin hook-heading-hero(){} -@mixin hook-heading-divider(){} -@mixin hook-heading-bullet(){} -@mixin hook-heading-line(){} -@mixin hook-heading-misc(){} -@mixin hook-inverse-heading-small(){} -@mixin hook-inverse-heading-medium(){} -@mixin hook-inverse-heading-large(){} -@mixin hook-inverse-heading-xlarge(){} -@mixin hook-inverse-heading-2xlarge(){} -@mixin hook-inverse-heading-primary(){} -@mixin hook-inverse-heading-hero(){} -@mixin hook-inverse-heading-divider(){} -@mixin hook-inverse-heading-bullet(){} -@mixin hook-inverse-heading-line(){} -@mixin hook-inverse-component-heading(){ - - .uk-heading-small { - @if(mixin-exists(hook-inverse-heading-small)) {@include hook-inverse-heading-small();} - } - - .uk-heading-medium { - @if(mixin-exists(hook-inverse-heading-medium)) {@include hook-inverse-heading-medium();} - } - - .uk-heading-large { - @if(mixin-exists(hook-inverse-heading-large)) {@include hook-inverse-heading-large();} - } - - .uk-heading-xlarge { - @if(mixin-exists(hook-inverse-heading-xlarge)) {@include hook-inverse-heading-xlarge();} - } - - .uk-heading-2xlarge { - @if(mixin-exists(hook-inverse-heading-2xlarge)) {@include hook-inverse-heading-2xlarge();} - } - - @if ($deprecated == true) { .uk-heading-primary { @if (mixin-exists(hook-inverse-heading-primary)) {@include hook-inverse-heading-primary();}}} - - @if ($deprecated == true) { .uk-heading-hero { @if (mixin-exists(hook-inverse-heading-hero)) {@include hook-inverse-heading-hero();}}} - - .uk-heading-divider { - border-bottom-color: $inverse-heading-divider-border; - @if(mixin-exists(hook-inverse-heading-divider)) {@include hook-inverse-heading-divider();} - } - - .uk-heading-bullet::before { - border-left-color: $inverse-heading-bullet-border; - @if(mixin-exists(hook-inverse-heading-bullet)) {@include hook-inverse-heading-bullet();} - } - - .uk-heading-line > ::before, - .uk-heading-line > ::after { - border-bottom-color: $inverse-heading-line-border; - @if(mixin-exists(hook-inverse-heading-line)) {@include hook-inverse-heading-line();} - } - -} -@mixin hook-height-misc(){} -@mixin hook-icon-link(){} -@mixin hook-icon-link-hover(){} -@mixin hook-icon-link-active(){} -@mixin hook-icon-button(){ - transition: 0.1s ease-in-out; - transition-property: color, background-color; -} -@mixin hook-icon-button-hover(){} -@mixin hook-icon-button-active(){} -@mixin hook-icon-misc(){} -@mixin hook-inverse-icon-link(){} -@mixin hook-inverse-icon-link-hover(){} -@mixin hook-inverse-icon-link-active(){} -@mixin hook-inverse-icon-button(){} -@mixin hook-inverse-icon-button-hover(){} -@mixin hook-inverse-icon-button-active(){} -@mixin hook-inverse-component-icon(){ - - // - // Link - // - - .uk-icon-link { - color: $inverse-icon-link-color; - @if(mixin-exists(hook-inverse-icon-link)) {@include hook-inverse-icon-link();} - } - - .uk-icon-link:hover, - .uk-icon-link:focus { - color: $inverse-icon-link-hover-color; - @if(mixin-exists(hook-inverse-icon-link-hover)) {@include hook-inverse-icon-link-hover();} - } - - .uk-icon-link:active, - .uk-active > .uk-icon-link { - color: $inverse-icon-link-active-color; - @if(mixin-exists(hook-inverse-icon-link-active)) {@include hook-inverse-icon-link-active();} - } - - // - // Button - // - - .uk-icon-button { - background-color: $inverse-icon-button-background; - color: $inverse-icon-button-color; - @if(mixin-exists(hook-inverse-icon-button)) {@include hook-inverse-icon-button();} - } - - .uk-icon-button:hover, - .uk-icon-button:focus { - background-color: $inverse-icon-button-hover-background; - color: $inverse-icon-button-hover-color; - @if(mixin-exists(hook-inverse-icon-button-hover)) {@include hook-inverse-icon-button-hover();} - } - - .uk-icon-button:active { - background-color: $inverse-icon-button-active-background; - color: $inverse-icon-button-active-color; - @if(mixin-exists(hook-inverse-icon-button-active)) {@include hook-inverse-icon-button-active();} - } - -} -@mixin hook-iconnav(){} -@mixin hook-iconnav-item(){ - font-size: $subnav-item-font-size; - transition: 0.1s ease-in-out; - transition-property: color, background-color; -} -@mixin hook-iconnav-item-hover(){} -@mixin hook-iconnav-item-active(){} -@mixin hook-iconnav-misc(){} -@mixin hook-inverse-iconnav-item(){} -@mixin hook-inverse-iconnav-item-hover(){} -@mixin hook-inverse-iconnav-item-active(){} -@mixin hook-inverse-component-iconnav(){ - - .uk-iconnav > * > a { - color: $inverse-iconnav-item-color; - @if(mixin-exists(hook-inverse-iconnav-item)) {@include hook-inverse-iconnav-item();} - } - - .uk-iconnav > * > a:hover, - .uk-iconnav > * > a:focus { - color: $inverse-iconnav-item-hover-color; - @if(mixin-exists(hook-inverse-iconnav-item-hover)) {@include hook-inverse-iconnav-item-hover();} - } - - .uk-iconnav > .uk-active > a { - color: $inverse-iconnav-item-active-color; - @if(mixin-exists(hook-inverse-iconnav-item-active)) {@include hook-inverse-iconnav-item-active();} - } - -} -@mixin hook-inverse-component-link(){ - - a.uk-link-muted, - .uk-link-muted a { - color: $inverse-link-muted-color; - @if(mixin-exists(hook-inverse-link-muted)) {@include hook-inverse-link-muted();} - } - - a.uk-link-muted:hover, - .uk-link-muted a:hover, - .uk-link-toggle:hover .uk-link-muted, - .uk-link-toggle:focus .uk-link-muted { - color: $inverse-link-muted-hover-color; - @if(mixin-exists(hook-inverse-link-muted-hover)) {@include hook-inverse-link-muted-hover();} - } - - a.uk-link-text:hover, - .uk-link-text a:hover, - .uk-link-toggle:hover .uk-link-text, - .uk-link-toggle:focus .uk-link-text { - color: $inverse-link-text-hover-color; - @if(mixin-exists(hook-inverse-link-text-hover)) {@include hook-inverse-link-text-hover();} - } - - a.uk-link-heading:hover, - .uk-link-heading a:hover, - .uk-link-toggle:hover .uk-link-heading, - .uk-link-toggle:focus .uk-link-heading { - color: $inverse-link-heading-hover-color; - @if(mixin-exists(hook-inverse-link-heading-hover)) {@include hook-inverse-link-heading-hover();} - } - -} -@mixin hook-inverse-component-list(){ - - .uk-list-muted > ::before { color: $inverse-list-muted-color !important; } - .uk-list-emphasis > ::before { color: $inverse-list-emphasis-color !important; } - .uk-list-primary > ::before { color: $inverse-list-primary-color !important; } - .uk-list-secondary > ::before { color: $inverse-list-secondary-color !important; } - - .uk-list-bullet > ::before { - @include svg-fill($internal-list-bullet-image, "#000", $inverse-list-bullet-icon-color); - } - - .uk-list-divider > :nth-child(n+2) { - border-top-color: $inverse-list-divider-border; - @if(mixin-exists(hook-inverse-list-divider)) {@include hook-inverse-list-divider();} - } - - .uk-list-striped > * { - @if(mixin-exists(hook-inverse-list-striped)) {@include hook-inverse-list-striped();} - } - - .uk-list-striped > :nth-of-type(odd) { background-color: $inverse-list-striped-background; } - -} -@mixin hook-inverse-component-totop(){ - - .uk-totop { - color: $inverse-totop-color; - @if(mixin-exists(hook-inverse-totop)) {@include hook-inverse-totop();} - } - - .uk-totop:hover, - .uk-totop:focus { - color: $inverse-totop-hover-color; - @if(mixin-exists(hook-inverse-totop-hover)) {@include hook-inverse-totop-hover();} - } - - .uk-totop:active { - color: $inverse-totop-active-color; - @if(mixin-exists(hook-inverse-totop-active)) {@include hook-inverse-totop-active();} - } - -} -@mixin hook-inverse-component-label(){ - - .uk-label { - background-color: $inverse-label-background; - color: $inverse-label-color; - @if(mixin-exists(hook-inverse-label)) {@include hook-inverse-label();} - } - -} -@mixin hook-inverse-component-search(){ - - // - // Input - // - - .uk-search-input { color: $inverse-search-color; } - - .uk-search-input:-ms-input-placeholder { color: $inverse-search-placeholder-color !important; } - .uk-search-input::placeholder { color: $inverse-search-placeholder-color; } - - - // - // Icon - // - - .uk-search .uk-search-icon { color: $inverse-search-icon-color; } - .uk-search .uk-search-icon:hover { color: $inverse-search-icon-color; } - - // - // Style modifier - // - - .uk-search-default .uk-search-input { - background-color: $inverse-search-default-background; - @if(mixin-exists(hook-inverse-search-default-input)) {@include hook-inverse-search-default-input();} - } - - .uk-search-default .uk-search-input:focus { - background-color: $inverse-search-default-focus-background; - @if(mixin-exists(hook-inverse-search-default-input-focus)) {@include hook-inverse-search-default-input-focus();} - } - - .uk-search-navbar .uk-search-input { - background-color: $inverse-search-navbar-background; - @if(mixin-exists(hook-inverse-search-navbar-input)) {@include hook-inverse-search-navbar-input();} - } - - .uk-search-large .uk-search-input { - background-color: $inverse-search-large-background; - @if(mixin-exists(hook-inverse-search-large-input)) {@include hook-inverse-search-large-input();} - } - - // - // Toggle - // - - .uk-search-toggle { - color: $inverse-search-toggle-color; - @if(mixin-exists(hook-inverse-search-toggle)) {@include hook-inverse-search-toggle();} - } - - .uk-search-toggle:hover, - .uk-search-toggle:focus { - color: $inverse-search-toggle-hover-color; - @if(mixin-exists(hook-inverse-search-toggle-hover)) {@include hook-inverse-search-toggle-hover();} - } - -} -@mixin hook-inverse-component-nav(){ - - // - // Parent icon modifier - // - - .uk-nav-parent-icon > .uk-parent > a::after { - @include svg-fill($internal-nav-parent-close-image, "#000", $inverse-nav-parent-icon-color); - @if(mixin-exists(hook-inverse-nav-parent-icon)) {@include hook-inverse-nav-parent-icon();} - } - - .uk-nav-parent-icon > .uk-parent.uk-open > a::after { @include svg-fill($internal-nav-parent-open-image, "#000", $inverse-nav-parent-icon-color); } - - // - // Default - // - - .uk-nav-default > li > a { - color: $inverse-nav-default-item-color; - @if(mixin-exists(hook-inverse-nav-default-item)) {@include hook-inverse-nav-default-item();} - } - - .uk-nav-default > li > a:hover, - .uk-nav-default > li > a:focus { - color: $inverse-nav-default-item-hover-color; - @if(mixin-exists(hook-inverse-nav-default-item-hover)) {@include hook-inverse-nav-default-item-hover();} - } - - .uk-nav-default > li.uk-active > a { - color: $inverse-nav-default-item-active-color; - @if(mixin-exists(hook-inverse-nav-default-item-active)) {@include hook-inverse-nav-default-item-active();} - } - - .uk-nav-default .uk-nav-header { - color: $inverse-nav-default-header-color; - @if(mixin-exists(hook-inverse-nav-default-header)) {@include hook-inverse-nav-default-header();} - } - - .uk-nav-default .uk-nav-divider { - border-top-color: $inverse-nav-default-divider-border; - @if(mixin-exists(hook-inverse-nav-default-divider)) {@include hook-inverse-nav-default-divider();} - } - - .uk-nav-default .uk-nav-sub a { color: $inverse-nav-default-sublist-item-color; } - - .uk-nav-default .uk-nav-sub a:hover, - .uk-nav-default .uk-nav-sub a:focus { color: $inverse-nav-default-sublist-item-hover-color; } - - .uk-nav-default .uk-nav-sub li.uk-active > a { color: $inverse-nav-default-sublist-item-active-color; } - - // - // Primary - // - - .uk-nav-primary > li > a { - color: $inverse-nav-primary-item-color; - @if(mixin-exists(hook-inverse-nav-primary-item)) {@include hook-inverse-nav-primary-item();} - } - - .uk-nav-primary > li > a:hover, - .uk-nav-primary > li > a:focus { - color: $inverse-nav-primary-item-hover-color; - @if(mixin-exists(hook-inverse-nav-primary-item-hover)) {@include hook-inverse-nav-primary-item-hover();} - } - - .uk-nav-primary > li.uk-active > a { - color: $inverse-nav-primary-item-active-color; - @if(mixin-exists(hook-inverse-nav-primary-item-active)) {@include hook-inverse-nav-primary-item-active();} - } - - .uk-nav-primary .uk-nav-header { - color: $inverse-nav-primary-header-color; - @if(mixin-exists(hook-inverse-nav-primary-header)) {@include hook-inverse-nav-primary-header();} - } - - .uk-nav-primary .uk-nav-divider { - border-top-color: $inverse-nav-primary-divider-border; - @if(mixin-exists(hook-inverse-nav-primary-divider)) {@include hook-inverse-nav-primary-divider();} - } - - .uk-nav-primary .uk-nav-sub a { color: $inverse-nav-primary-sublist-item-color; } - - .uk-nav-primary .uk-nav-sub a:hover, - .uk-nav-primary .uk-nav-sub a:focus { color: $inverse-nav-primary-sublist-item-hover-color; } - - .uk-nav-primary .uk-nav-sub li.uk-active > a { color: $inverse-nav-primary-sublist-item-active-color; } - - // - // Dividers - // - - .uk-nav.uk-nav-divider > :not(.uk-nav-divider) + :not(.uk-nav-header, .uk-nav-divider) { - border-top-color: $inverse-nav-dividers-border; - @if(mixin-exists(hook-nav-dividers)) {@include hook-nav-dividers();} - } - -} -@mixin hook-inverse-component-navbar(){ - - .uk-navbar-nav > li > a { - color: $inverse-navbar-nav-item-color; - @if(mixin-exists(hook-inverse-navbar-nav-item)) {@include hook-inverse-navbar-nav-item();} - } - - .uk-navbar-nav > li:hover > a, - .uk-navbar-nav > li > a:focus, - .uk-navbar-nav > li > a.uk-open { - color: $inverse-navbar-nav-item-hover-color; - @if(mixin-exists(hook-inverse-navbar-nav-item-hover)) {@include hook-inverse-navbar-nav-item-hover();} - } - - .uk-navbar-nav > li > a:active { - color: $inverse-navbar-nav-item-onclick-color; - @if(mixin-exists(hook-inverse-navbar-nav-item-onclick)) {@include hook-inverse-navbar-nav-item-onclick();} - } - - .uk-navbar-nav > li.uk-active > a { - color: $inverse-navbar-nav-item-active-color; - @if(mixin-exists(hook-inverse-navbar-nav-item-active)) {@include hook-inverse-navbar-nav-item-active();} - } - - .uk-navbar-item { - color: $inverse-navbar-item-color; - @if(mixin-exists(hook-inverse-navbar-item)) {@include hook-inverse-navbar-item();} - } - - .uk-navbar-toggle { - color: $inverse-navbar-toggle-color; - @if(mixin-exists(hook-inverse-navbar-toggle)) {@include hook-inverse-navbar-toggle();} - } - - .uk-navbar-toggle:hover, - .uk-navbar-toggle:focus, - .uk-navbar-toggle.uk-open { - color: $inverse-navbar-toggle-hover-color; - @if(mixin-exists(hook-inverse-navbar-toggle-hover)) {@include hook-inverse-navbar-toggle-hover();} - } - -} -@mixin hook-inverse-component-subnav(){ - - .uk-subnav > * > :first-child { - color: $inverse-subnav-item-color; - @if(mixin-exists(hook-inverse-subnav-item)) {@include hook-inverse-subnav-item();} - } - - .uk-subnav > * > a:hover, - .uk-subnav > * > a:focus { - color: $inverse-subnav-item-hover-color; - @if(mixin-exists(hook-inverse-subnav-item-hover)) {@include hook-inverse-subnav-item-hover();} - } - - .uk-subnav > .uk-active > a { - color: $inverse-subnav-item-active-color; - @if(mixin-exists(hook-inverse-subnav-item-active)) {@include hook-inverse-subnav-item-active();} - } - - // - // Divider - // - - .uk-subnav-divider > :nth-child(n+2):not(.uk-first-column)::before { - border-left-color: $inverse-subnav-divider-border; - @if(mixin-exists(hook-inverse-subnav-divider)) {@include hook-inverse-subnav-divider();} - } - - // - // Pill - // - - .uk-subnav-pill > * > :first-child { - background-color: $inverse-subnav-pill-item-background; - color: $inverse-subnav-pill-item-color; - @if(mixin-exists(hook-inverse-subnav-pill-item)) {@include hook-inverse-subnav-pill-item();} - } - - .uk-subnav-pill > * > a:hover, - .uk-subnav-pill > * > a:focus { - background-color: $inverse-subnav-pill-item-hover-background; - color: $inverse-subnav-pill-item-hover-color; - @if(mixin-exists(hook-inverse-subnav-pill-item-hover)) {@include hook-inverse-subnav-pill-item-hover();} - } - - .uk-subnav-pill > * > a:active { - background-color: $inverse-subnav-pill-item-onclick-background; - color: $inverse-subnav-pill-item-onclick-color; - @if(mixin-exists(hook-inverse-subnav-pill-item-onclick)) {@include hook-inverse-subnav-pill-item-onclick();} - } - - .uk-subnav-pill > .uk-active > a { - background-color: $inverse-subnav-pill-item-active-background; - color: $inverse-subnav-pill-item-active-color; - @if(mixin-exists(hook-inverse-subnav-pill-item-active)) {@include hook-inverse-subnav-pill-item-active();} - } - - // - // Disabled - // - - .uk-subnav > .uk-disabled > a { - color: $inverse-subnav-item-disabled-color; - @if(mixin-exists(hook-inverse-subnav-item-disabled)) {@include hook-inverse-subnav-item-disabled();} - } - -} -@mixin hook-inverse-component-pagination(){ - - .uk-pagination > * > * { - color: $inverse-pagination-item-color; - @if(mixin-exists(hook-inverse-pagination-item)) {@include hook-inverse-pagination-item();} - } - - .uk-pagination > * > :hover, - .uk-pagination > * > :focus { - color: $inverse-pagination-item-hover-color; - @if(mixin-exists(hook-inverse-pagination-item-hover)) {@include hook-inverse-pagination-item-hover();} - } - - .uk-pagination > .uk-active > * { - color: $inverse-pagination-item-active-color; - @if(mixin-exists(hook-inverse-pagination-item-active)) {@include hook-inverse-pagination-item-active();} - } - - .uk-pagination > .uk-disabled > * { - color: $inverse-pagination-item-disabled-color; - @if(mixin-exists(hook-inverse-pagination-item-disabled)) {@include hook-inverse-pagination-item-disabled();} - } - -} -@mixin hook-inverse-component-tab(){ - - .uk-tab { - @if(mixin-exists(hook-inverse-tab)) {@include hook-inverse-tab();} - } - - .uk-tab > * > a { - color: $inverse-tab-item-color; - @if(mixin-exists(hook-inverse-tab-item)) {@include hook-inverse-tab-item();} - } - - .uk-tab > * > a:hover, - .uk-tab > * > a:focus{ - color: $inverse-tab-item-hover-color; - @if(mixin-exists(hook-inverse-tab-item-hover)) {@include hook-inverse-tab-item-hover();} - } - - .uk-tab > .uk-active > a { - color: $inverse-tab-item-active-color; - @if(mixin-exists(hook-inverse-tab-item-active)) {@include hook-inverse-tab-item-active();} - } - - .uk-tab > .uk-disabled > a { - color: $inverse-tab-item-disabled-color; - @if(mixin-exists(hook-inverse-tab-item-disabled)) {@include hook-inverse-tab-item-disabled();} - } - -} -@mixin hook-inverse-component-slidenav(){ - - .uk-slidenav { - color: $inverse-slidenav-color; - @if(mixin-exists(hook-inverse-slidenav)) {@include hook-inverse-slidenav();} - } - - .uk-slidenav:hover, - .uk-slidenav:focus { - color: $inverse-slidenav-hover-color; - @if(mixin-exists(hook-inverse-slidenav-hover)) {@include hook-inverse-slidenav-hover();} - } - - .uk-slidenav:active { - color: $inverse-slidenav-active-color; - @if(mixin-exists(hook-inverse-slidenav-active)) {@include hook-inverse-slidenav-active();} - } - -} -@mixin hook-inverse-component-text(){ - - .uk-text-lead { - color: $inverse-text-lead-color; - @if(mixin-exists(hook-inverse-text-lead)) {@include hook-inverse-text-lead();} - } - - .uk-text-meta { - color: $inverse-text-meta-color; - @if(mixin-exists(hook-inverse-text-meta)) {@include hook-inverse-text-meta();} - } - - .uk-text-muted { color: $inverse-text-muted-color !important; } - .uk-text-emphasis { color: $inverse-text-emphasis-color !important; } - .uk-text-primary { color: $inverse-text-primary-color !important; } - .uk-text-secondary { color: $inverse-text-secondary-color !important; } - -} -@mixin hook-inverse-component-utility(){ - - .uk-dropcap::first-letter, - .uk-dropcap p:first-of-type::first-letter { - @if(mixin-exists(hook-inverse-dropcap)) {@include hook-inverse-dropcap();} - } - - .uk-logo { - color: $inverse-logo-color; - @if(mixin-exists(hook-inverse-logo)) {@include hook-inverse-logo();} - } - - .uk-logo:hover, - .uk-logo:focus { - color: $inverse-logo-hover-color; - @if(mixin-exists(hook-inverse-logo-hover)) {@include hook-inverse-logo-hover();} - } - - .uk-logo > :not(.uk-logo-inverse):not(:only-of-type) { display: none; } - .uk-logo-inverse { display: inline; } - -} -@mixin hook-inverse(){ - @include hook-inverse-component-base(); - @include hook-inverse-component-link(); - @include hook-inverse-component-heading(); - @include hook-inverse-component-divider(); - @include hook-inverse-component-list(); - @include hook-inverse-component-icon(); - @include hook-inverse-component-form(); - @include hook-inverse-component-button(); - @include hook-inverse-component-grid(); - @include hook-inverse-component-close(); - @include hook-inverse-component-totop(); - @include hook-inverse-component-badge(); - @include hook-inverse-component-label(); - @include hook-inverse-component-article(); - @include hook-inverse-component-search(); - @include hook-inverse-component-nav(); - @include hook-inverse-component-navbar(); - @include hook-inverse-component-subnav(); - @include hook-inverse-component-breadcrumb(); - @include hook-inverse-component-pagination(); - @include hook-inverse-component-tab(); - @include hook-inverse-component-slidenav(); - @include hook-inverse-component-dotnav(); - @include hook-inverse-component-accordion(); - @include hook-inverse-component-iconnav(); - @include hook-inverse-component-text(); - @include hook-inverse-component-column(); - @include hook-inverse-component-utility(); -} -@mixin hook-label(){ - border-radius: $label-border-radius; - text-transform: $label-text-transform; -} -@mixin hook-label-success(){} -@mixin hook-label-warning(){} -@mixin hook-label-danger(){} -@mixin hook-label-misc(){} -@mixin hook-inverse-label(){} -@mixin hook-leader(){} -@mixin hook-leader-misc(){} -@mixin hook-inverse-leader(){} -@mixin hook-inverse-component-leader(){ - - .uk-leader-fill::after { - @if(mixin-exists(hook-inverse-leader)) {@include hook-inverse-leader();} - } - -} -@mixin hook-lightbox(){} -@mixin hook-lightbox-item(){} -@mixin hook-lightbox-toolbar(){} -@mixin hook-lightbox-toolbar-icon(){} -@mixin hook-lightbox-toolbar-icon-hover(){} -@mixin hook-lightbox-button(){} -@mixin hook-lightbox-button-hover(){} -@mixin hook-lightbox-button-active(){} -@mixin hook-lightbox-misc(){} -@mixin hook-link-muted(){} -@mixin hook-link-muted-hover(){} -@mixin hook-link-text(){} -@mixin hook-link-text-hover(){} -@mixin hook-link-heading(){} -@mixin hook-link-heading-hover(){} -@mixin hook-link-reset(){} -@mixin hook-link-misc(){} -@mixin hook-inverse-link-muted(){} -@mixin hook-inverse-link-muted-hover(){} -@mixin hook-inverse-link-text-hover(){} -@mixin hook-inverse-link-heading-hover(){} -@mixin hook-list-divider(){} -@mixin hook-list-striped(){ - - &:nth-of-type(odd) { - border-top: $list-striped-border-width solid $list-striped-border; - border-bottom: $list-striped-border-width solid $list-striped-border; - } - -} -@mixin hook-list-misc(){} -@mixin hook-inverse-list-divider(){} -@mixin hook-inverse-list-striped(){ - - &:nth-of-type(odd) { - border-top-color: $inverse-global-border; - border-bottom-color: $inverse-global-border; - } - -} -@mixin hook-margin-misc(){} -@mixin hook-marker(){ - border-radius: 500px; -} -@mixin hook-marker-hover(){} -@mixin hook-marker-misc(){} -@mixin hook-inverse-marker(){} -@mixin hook-inverse-marker-hover(){} -@mixin hook-inverse-component-marker(){ - - .uk-marker { - background: $inverse-marker-background; - color: $inverse-marker-color; - @if(mixin-exists(hook-inverse-marker)) {@include hook-inverse-marker();} - } - - .uk-marker:hover, - .uk-marker:focus { - color: $inverse-marker-hover-color; - @if(mixin-exists(hook-inverse-marker-hover)) {@include hook-inverse-marker-hover();} - } - -} -@mixin hook-modal(){} -@mixin hook-modal-dialog(){} -@mixin hook-modal-full(){} -@mixin hook-modal-body(){} -@mixin hook-modal-header(){ border-bottom: $modal-header-border-width solid $modal-header-border; } -@mixin hook-modal-footer(){ border-top: $modal-footer-border-width solid $modal-footer-border; } -@mixin hook-modal-title(){} -@mixin hook-modal-close(){} -@mixin hook-modal-close-hover(){} -@mixin hook-modal-close-default(){} -@mixin hook-modal-close-default-hover(){} -@mixin hook-modal-close-outside(){} -@mixin hook-modal-close-outside-hover(){} -@mixin hook-modal-close-full(){ - top: 0; - right: 0; - padding: $modal-close-full-padding; - background: $modal-close-full-background; -} -@mixin hook-modal-close-full-hover(){} -@mixin hook-modal-misc(){} -@mixin hook-nav-sub(){} -@mixin hook-nav-parent-icon(){} -@mixin hook-nav-header(){} -@mixin hook-nav-divider(){} -@mixin hook-nav-default(){ font-size: $nav-default-font-size; } -@mixin hook-nav-default-item(){} -@mixin hook-nav-default-item-hover(){} -@mixin hook-nav-default-item-active(){} -@mixin hook-nav-default-header(){} -@mixin hook-nav-default-divider(){} -@mixin hook-nav-primary(){} -@mixin hook-nav-primary-item(){} -@mixin hook-nav-primary-item-hover(){} -@mixin hook-nav-primary-item-active(){} -@mixin hook-nav-primary-header(){} -@mixin hook-nav-primary-divider(){} -@mixin hook-nav-dividers(){} -@mixin hook-nav-misc(){} -@mixin hook-inverse-nav-parent-icon(){} -@mixin hook-inverse-nav-default-item(){} -@mixin hook-inverse-nav-default-item-hover(){} -@mixin hook-inverse-nav-default-item-active(){} -@mixin hook-inverse-nav-default-header(){} -@mixin hook-inverse-nav-default-divider(){} -@mixin hook-inverse-nav-primary-item(){} -@mixin hook-inverse-nav-primary-item-hover(){} -@mixin hook-inverse-nav-primary-item-active(){} -@mixin hook-inverse-nav-primary-header(){} -@mixin hook-inverse-nav-primary-divider(){} -@mixin hook-navbar(){} -@mixin hook-navbar-container(){} -@mixin hook-navbar-nav-item(){ - text-transform: $navbar-nav-item-text-transform; - transition: 0.1s ease-in-out; - transition-property: color, background-color; -} -@mixin hook-navbar-nav-item-hover(){} -@mixin hook-navbar-nav-item-onclick(){} -@mixin hook-navbar-nav-item-active(){} -@mixin hook-navbar-item(){} -@mixin hook-navbar-toggle(){} -@mixin hook-navbar-toggle-hover(){} -@mixin hook-navbar-toggle-icon(){} -@mixin hook-navbar-toggle-icon-hover(){} -@mixin hook-navbar-subtitle(){} -@mixin hook-navbar-primary(){} -@mixin hook-navbar-transparent(){} -@mixin hook-navbar-sticky(){} -@mixin hook-navbar-dropdown(){ box-shadow: $navbar-dropdown-box-shadow; } -@mixin hook-navbar-dropdown-dropbar(){ box-shadow: none; } -@mixin hook-navbar-dropdown-nav(){ font-size: $navbar-dropdown-nav-font-size; } -@mixin hook-navbar-dropdown-nav-item(){} -@mixin hook-navbar-dropdown-nav-item-hover(){} -@mixin hook-navbar-dropdown-nav-item-active(){} -@mixin hook-navbar-dropdown-nav-header(){} -@mixin hook-navbar-dropdown-nav-divider(){} -@mixin hook-navbar-dropbar(){} -@mixin hook-navbar-dropbar-slide(){ box-shadow: $navbar-dropbar-box-shadow; } -@mixin hook-navbar-misc(){ - - /* - * Navbar - */ - - .uk-navbar-container > .uk-container .uk-navbar-left { - margin-left: (-$navbar-nav-item-padding-horizontal); - margin-right: (-$navbar-nav-item-padding-horizontal); - } - .uk-navbar-container > .uk-container .uk-navbar-right { margin-right: (-$navbar-nav-item-padding-horizontal); } - - /* - * Grid Divider - */ - - .uk-navbar-dropdown-grid > * { position: relative; } - - .uk-navbar-dropdown-grid > :not(.uk-first-column)::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: ($navbar-dropdown-grid-gutter-horizontal / 2); - border-left: $navbar-dropdown-grid-divider-border-width solid $navbar-dropdown-grid-divider-border; - } - - /* Vertical */ - .uk-navbar-dropdown-grid.uk-grid-stack > .uk-grid-margin::before { - content: ""; - position: absolute; - top: -($navbar-dropdown-grid-gutter-vertical / 2); - left: $navbar-dropdown-grid-gutter-horizontal; - right: 0; - border-top: $navbar-dropdown-grid-divider-border-width solid $navbar-dropdown-grid-divider-border; - } - -} -@mixin hook-inverse-navbar-nav-item(){} -@mixin hook-inverse-navbar-nav-item-hover(){} -@mixin hook-inverse-navbar-nav-item-onclick(){} -@mixin hook-inverse-navbar-nav-item-active(){} -@mixin hook-inverse-navbar-item(){} -@mixin hook-inverse-navbar-toggle(){} -@mixin hook-inverse-navbar-toggle-hover(){} -@mixin hook-notification(){} -@mixin hook-notification-message(){} -@mixin hook-notification-close(){} -@mixin hook-notification-message-primary(){} -@mixin hook-notification-message-success(){} -@mixin hook-notification-message-warning(){} -@mixin hook-notification-message-danger(){} -@mixin hook-notification-misc(){} -@mixin hook-offcanvas-bar(){} -@mixin hook-offcanvas-close(){} -@mixin hook-offcanvas-overlay(){} -@mixin hook-offcanvas-misc(){} -@mixin hook-overlay(){} -@mixin hook-overlay-icon(){} -@mixin hook-overlay-default(){} -@mixin hook-overlay-primary(){} -@mixin hook-overlay-misc(){} -@mixin hook-padding-misc(){} -@mixin hook-pagination(){} -@mixin hook-pagination-item(){ transition: color 0.1s ease-in-out; } -@mixin hook-pagination-item-hover(){} -@mixin hook-pagination-item-active(){} -@mixin hook-pagination-item-disabled(){} -@mixin hook-pagination-misc(){} -@mixin hook-inverse-pagination-item(){} -@mixin hook-inverse-pagination-item-hover(){} -@mixin hook-inverse-pagination-item-active(){} -@mixin hook-inverse-pagination-item-disabled(){} -@mixin hook-placeholder(){ border: $placeholder-border-width dashed $placeholder-border; } -@mixin hook-placeholder-misc(){} -@mixin hook-position-misc(){} -@mixin hook-print(){} -@mixin hook-progress(){ - border-radius: $progress-border-radius; - overflow: hidden; -} -@mixin hook-progress-bar(){} -@mixin hook-progress-misc(){} -@mixin hook-search-input(){} -@mixin hook-search-default-input(){ border: $search-default-border-width solid $search-default-border; } -@mixin hook-search-default-input-focus(){ border-color: $search-default-focus-border; } -@mixin hook-search-navbar-input(){} -@mixin hook-search-large-input(){} -@mixin hook-search-toggle(){} -@mixin hook-search-toggle-hover(){} -@mixin hook-search-misc(){} -@mixin hook-inverse-search-default-input(){ border-color: $inverse-global-border; } -@mixin hook-inverse-search-default-input-focus(){} -@mixin hook-inverse-search-navbar-input(){} -@mixin hook-inverse-search-large-input(){} -@mixin hook-inverse-search-toggle(){} -@mixin hook-inverse-search-toggle-hover(){} -@mixin hook-section(){} -@mixin hook-section-default(){} -@mixin hook-section-muted(){} -@mixin hook-section-primary(){} -@mixin hook-section-secondary(){} -@mixin hook-section-overlap(){} -@mixin hook-section-misc(){} -@mixin hook-slidenav(){ transition: color 0.1s ease-in-out; } -@mixin hook-slidenav-hover(){} -@mixin hook-slidenav-active(){} -@mixin hook-slidenav-previous(){} -@mixin hook-slidenav-next(){} -@mixin hook-slidenav-large(){} -@mixin hook-slidenav-container(){} -@mixin hook-slidenav-misc(){} -@mixin hook-inverse-slidenav(){} -@mixin hook-inverse-slidenav-hover(){} -@mixin hook-inverse-slidenav-active(){} -@mixin hook-slider(){} -@mixin hook-slider-misc(){} -@mixin hook-slideshow(){} -@mixin hook-slideshow-misc(){} -@mixin hook-sortable(){} -@mixin hook-sortable-drag(){} -@mixin hook-sortable-placeholder(){} -@mixin hook-sortable-empty(){} -@mixin hook-sortable-misc(){} -@mixin hook-spinner(){} -@mixin hook-spinner-misc(){} -@mixin hook-sticky-misc(){} -@mixin hook-subnav(){} -@mixin hook-subnav-item(){ - font-size: $subnav-item-font-size; - text-transform: $subnav-item-text-transform; - transition: 0.1s ease-in-out; - transition-property: color, background-color; -} -@mixin hook-subnav-item-hover(){} -@mixin hook-subnav-item-active(){} -@mixin hook-subnav-divider(){} -@mixin hook-subnav-pill-item(){} -@mixin hook-subnav-pill-item-hover(){} -@mixin hook-subnav-pill-item-onclick(){} -@mixin hook-subnav-pill-item-active(){} -@mixin hook-subnav-item-disabled(){} -@mixin hook-subnav-misc(){} -@mixin hook-inverse-subnav-item(){} -@mixin hook-inverse-subnav-item-hover(){} -@mixin hook-inverse-subnav-item-active(){} -@mixin hook-inverse-subnav-divider(){} -@mixin hook-inverse-subnav-pill-item(){} -@mixin hook-inverse-subnav-pill-item-hover(){} -@mixin hook-inverse-subnav-pill-item-onclick(){} -@mixin hook-inverse-subnav-pill-item-active(){} -@mixin hook-inverse-subnav-item-disabled(){} -@mixin hook-svg-misc(){} -@mixin hook-switcher-misc(){} -@mixin hook-tab(){ - - position: relative; - - &::before { - content: ""; - position: absolute; - bottom: 0; - left: $tab-margin-horizontal; - right: 0; - border-bottom: $tab-border-width solid $tab-border; - } - -} -@mixin hook-tab-item(){ - border-bottom: $tab-item-border-width solid transparent; - font-size: $tab-item-font-size; - text-transform: $tab-item-text-transform; - transition: color 0.1s ease-in-out; -} -@mixin hook-tab-item-hover(){} -@mixin hook-tab-item-active(){ border-color: $tab-item-active-border; } -@mixin hook-tab-item-disabled(){} -@mixin hook-tab-bottom(){ - - &::before { - top: 0; - bottom: auto; - } - -} -@mixin hook-tab-bottom-item(){ - border-top: $tab-item-border-width solid transparent; - border-bottom: none; -} -@mixin hook-tab-left(){ - - &::before { - top: 0; - bottom: 0; - left: auto; - right: 0; - border-left: $tab-border-width solid $tab-border; - border-bottom: none; - } - -} -@mixin hook-tab-right(){ - - &::before { - top: 0; - bottom: 0; - left: 0; - right: auto; - border-left: $tab-border-width solid $tab-border; - border-bottom: none; - } - -} -@mixin hook-tab-left-item(){ - border-right: $tab-item-border-width solid transparent; - border-bottom: none; -} -@mixin hook-tab-right-item(){ - border-left: $tab-item-border-width solid transparent; - border-bottom: none; -} -@mixin hook-tab-misc(){ - - .uk-tab .uk-dropdown { margin-left: ($tab-margin-horizontal + $tab-item-padding-horizontal) } - -} -@mixin hook-inverse-tab(){ - - &::before { border-color: $inverse-tab-border; } - -} -@mixin hook-inverse-tab-item(){} -@mixin hook-inverse-tab-item-hover(){} -@mixin hook-inverse-tab-item-active(){ border-color: $inverse-global-primary-background; } -@mixin hook-inverse-tab-item-disabled(){} -@mixin hook-table(){} -@mixin hook-table-header-cell(){ text-transform: uppercase; } -@mixin hook-table-cell(){} -@mixin hook-table-footer(){} -@mixin hook-table-caption(){} -@mixin hook-table-divider(){} -@mixin hook-table-striped(){ - border-top: $table-striped-border-width solid $table-striped-border; - border-bottom: $table-striped-border-width solid $table-striped-border; -} -@mixin hook-table-hover(){} -@mixin hook-table-row-active(){} -@mixin hook-table-small(){} -@mixin hook-table-large(){} -@mixin hook-table-misc(){ - - .uk-table tbody tr { transition: background-color 0.1s linear; } - -} -@mixin hook-inverse-table-header-cell(){} -@mixin hook-inverse-table-caption(){} -@mixin hook-inverse-table-row-active(){} -@mixin hook-inverse-table-divider(){} -@mixin hook-inverse-table-striped(){ - border-top-color: $inverse-global-border; - border-bottom-color: $inverse-global-border; -} -@mixin hook-inverse-table-hover(){} -@mixin hook-inverse-component-table(){ - - .uk-table th { - color: $inverse-table-header-cell-color; - @if(mixin-exists(hook-inverse-table-header-cell)) {@include hook-inverse-table-header-cell();} - } - - .uk-table caption { - color: $inverse-table-caption-color; - @if(mixin-exists(hook-inverse-table-caption)) {@include hook-inverse-table-caption();} - } - - .uk-table > tr.uk-active, - .uk-table tbody tr.uk-active { - background: $inverse-table-row-active-background; - @if(mixin-exists(hook-inverse-table-row-active)) {@include hook-inverse-table-row-active();} - } - - .uk-table-divider > tr:not(:first-child), - .uk-table-divider > :not(:first-child) > tr, - .uk-table-divider > :first-child > tr:not(:first-child) { - border-top-color: $inverse-table-divider-border; - @if(mixin-exists(hook-inverse-table-divider)) {@include hook-inverse-table-divider();} - } - - .uk-table-striped > tr:nth-of-type(odd), - .uk-table-striped tbody tr:nth-of-type(odd) { - background: $inverse-table-striped-row-background; - @if(mixin-exists(hook-inverse-table-striped)) {@include hook-inverse-table-striped();} - } - - .uk-table-hover > tr:hover, - .uk-table-hover tbody tr:hover { - background: $inverse-table-hover-row-background; - @if(mixin-exists(hook-inverse-table-hover)) {@include hook-inverse-table-hover();} - } - -} -@mixin hook-text-lead(){} -@mixin hook-text-meta(){ - - a { color: $text-meta-link-color; } - - a:hover { - color: $text-meta-link-hover-color; - text-decoration: none; - } - -} -@mixin hook-text-small(){} -@mixin hook-text-large(){} -@mixin hook-text-background(){} -@mixin hook-text-misc(){} -@mixin hook-inverse-text-lead(){} -@mixin hook-inverse-text-meta(){} -@mixin hook-thumbnav(){} -@mixin hook-thumbnav-item(){ - - position: relative; - - &::after { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - background: $thumbnav-item-background; - transition: background-color 0.1s ease-in-out; - } - -} -@mixin hook-thumbnav-item-hover(){ - &::after { background-color: $thumbnav-item-hover-background; } -} -@mixin hook-thumbnav-item-active(){ - &::after { background-color: $thumbnav-item-active-background; } -} -@mixin hook-thumbnav-misc(){} -@mixin hook-inverse-thumbnav-item(){} -@mixin hook-inverse-thumbnav-item-hover(){} -@mixin hook-inverse-thumbnav-item-active(){} -@mixin hook-inverse-component-thumbnav(){ - - .uk-thumbnav > * > * { - @if(mixin-exists(hook-inverse-thumbnav-item)) {@include hook-inverse-thumbnav-item();} - } - - .uk-thumbnav > * > :hover, - .uk-thumbnav > * > :focus { - @if(mixin-exists(hook-inverse-thumbnav-item-hover)) {@include hook-inverse-thumbnav-item-hover();} - } - - .uk-thumbnav > .uk-active > * { - @if(mixin-exists(hook-inverse-thumbnav-item-active)) {@include hook-inverse-thumbnav-item-active();} - } - -} -@mixin hook-tile(){} -@mixin hook-tile-default(){} -@mixin hook-tile-muted(){} -@mixin hook-tile-primary(){} -@mixin hook-tile-secondary(){} -@mixin hook-tile-misc(){} -@mixin hook-tooltip(){} -@mixin hook-tooltip-misc(){} -@mixin hook-totop(){ transition: color 0.1s ease-in-out; } -@mixin hook-totop-hover(){} -@mixin hook-totop-active(){} -@mixin hook-totop-misc(){} -@mixin hook-inverse-totop(){} -@mixin hook-inverse-totop-hover(){} -@mixin hook-inverse-totop-active(){} -@mixin hook-transition-misc(){} -@mixin hook-panel-scrollable(){} -@mixin hook-box-shadow-bottom(){} -@mixin hook-dropcap(){ - // Prevent line wrap - margin-bottom: -2px; -} -@mixin hook-logo(){} -@mixin hook-logo-hover(){} -@mixin hook-utility-misc(){} -@mixin hook-inverse-dropcap(){} -@mixin hook-inverse-logo(){} -@mixin hook-inverse-logo-hover(){} -@mixin hook-visibility-misc(){} -@mixin hook-width-misc(){} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/_sass/uikit/mixins.scss b/docs/_sass/uikit/mixins.scss deleted file mode 100644 index 61bae21e37..0000000000 --- a/docs/_sass/uikit/mixins.scss +++ /dev/null @@ -1,1748 +0,0 @@ -@mixin hook-accordion(){} -@mixin hook-accordion-item(){} -@mixin hook-accordion-title(){} -@mixin hook-accordion-title-hover(){} -@mixin hook-accordion-content(){} -@mixin hook-accordion-misc(){} -@mixin hook-inverse-accordion-item(){} -@mixin hook-inverse-accordion-title(){} -@mixin hook-inverse-accordion-title-hover(){} -@mixin hook-inverse-component-accordion(){ - - .uk-accordion > :nth-child(n+2) { - @if(mixin-exists(hook-inverse-accordion-item)) {@include hook-inverse-accordion-item();} - } - - .uk-accordion-title { - color: $inverse-accordion-title-color; - @if(mixin-exists(hook-inverse-accordion-title)) {@include hook-inverse-accordion-title();} - } - - .uk-accordion-title:hover, - .uk-accordion-title:focus { - color: $inverse-accordion-title-hover-color; - @if(mixin-exists(hook-inverse-accordion-title-hover)) {@include hook-inverse-accordion-title-hover();} - } - -} -@mixin hook-alert(){} -@mixin hook-alert-close(){} -@mixin hook-alert-close-hover(){} -@mixin hook-alert-primary(){} -@mixin hook-alert-success(){} -@mixin hook-alert-warning(){} -@mixin hook-alert-danger(){} -@mixin hook-alert-misc(){} -@mixin hook-align-misc(){} -@mixin hook-animation-misc(){} -@mixin hook-article(){} -@mixin hook-article-adjacent(){} -@mixin hook-article-title(){} -@mixin hook-article-meta(){} -@mixin hook-article-misc(){} -@mixin hook-inverse-article-title(){} -@mixin hook-inverse-article-meta(){} -@mixin hook-inverse-component-article(){ - - .uk-article-title { - @if(mixin-exists(hook-inverse-article-title)) {@include hook-inverse-article-title();} - } - - .uk-article-meta { - color: $inverse-article-meta-color; - @if(mixin-exists(hook-inverse-article-meta)) {@include hook-inverse-article-meta();} - } - -} -@mixin hook-background-misc(){} -@mixin hook-badge(){} -@mixin hook-badge-hover(){} -@mixin hook-badge-misc(){} -@mixin hook-inverse-badge(){} -@mixin hook-inverse-badge-hover(){} -@mixin hook-inverse-component-badge(){ - - .uk-badge { - background-color: $inverse-badge-background; - color: $inverse-badge-color !important; - @if(mixin-exists(hook-inverse-badge)) {@include hook-inverse-badge();} - } - - .uk-badge:hover, - .uk-badge:focus { - @if(mixin-exists(hook-inverse-badge-hover)) {@include hook-inverse-badge-hover();} - } - -} -@mixin hook-base-body(){} -@mixin hook-base-link(){} -@mixin hook-base-link-hover(){} -@mixin hook-base-code(){} -@mixin hook-base-heading(){} -@mixin hook-base-h1(){} -@mixin hook-base-h2(){} -@mixin hook-base-h3(){} -@mixin hook-base-h4(){} -@mixin hook-base-h5(){} -@mixin hook-base-h6(){} -@mixin hook-base-hr(){} -@mixin hook-base-blockquote(){} -@mixin hook-base-blockquote-footer(){} -@mixin hook-base-pre(){} -@mixin hook-base-misc(){} -@mixin hook-inverse-base-link(){} -@mixin hook-inverse-base-link-hover(){} -@mixin hook-inverse-base-code(){} -@mixin hook-inverse-base-heading(){} -@mixin hook-inverse-base-h1(){} -@mixin hook-inverse-base-h2(){} -@mixin hook-inverse-base-h3(){} -@mixin hook-inverse-base-h4(){} -@mixin hook-inverse-base-h5(){} -@mixin hook-inverse-base-h6(){} -@mixin hook-inverse-base-blockquote(){} -@mixin hook-inverse-base-blockquote-footer(){} -@mixin hook-inverse-base-hr(){} -@mixin hook-inverse-component-base(){ - - color: $inverse-base-color; - - // Base - // ======================================================================== - - // - // Link - // - - a, - .uk-link { - color: $inverse-base-link-color; - @if(mixin-exists(hook-inverse-base-link)) {@include hook-inverse-base-link();} - } - - a:hover, - .uk-link:hover, - .uk-link-toggle:hover .uk-link, - .uk-link-toggle:focus .uk-link { - color: $inverse-base-link-hover-color; - @if(mixin-exists(hook-inverse-base-link-hover)) {@include hook-inverse-base-link-hover();} - } - - // - // Code - // - - :not(pre) > code, - :not(pre) > kbd, - :not(pre) > samp { - color: $inverse-base-code-color; - @if(mixin-exists(hook-inverse-base-code)) {@include hook-inverse-base-code();} - } - - // - // Emphasize - // - - em { color: $inverse-base-em-color; } - - // - // Headings - // - - h1, .uk-h1, - h2, .uk-h2, - h3, .uk-h3, - h4, .uk-h4, - h5, .uk-h5, - h6, .uk-h6, - .uk-heading-small, - .uk-heading-medium, - .uk-heading-large, - .uk-heading-xlarge, - .uk-heading-2xlarge { - color: $inverse-base-heading-color; - @if(mixin-exists(hook-inverse-base-heading)) {@include hook-inverse-base-heading();} - } - - h1, .uk-h1 { - @if(mixin-exists(hook-inverse-base-h1)) {@include hook-inverse-base-h1();} - } - - h2, .uk-h2 { - @if(mixin-exists(hook-inverse-base-h2)) {@include hook-inverse-base-h2();} - } - - h3, .uk-h3 { - @if(mixin-exists(hook-inverse-base-h3)) {@include hook-inverse-base-h3();} - } - - h4, .uk-h4 { - @if(mixin-exists(hook-inverse-base-h4)) {@include hook-inverse-base-h4();} - } - - h5, .uk-h5 { - @if(mixin-exists(hook-inverse-base-h5)) {@include hook-inverse-base-h5();} - } - - h6, .uk-h6 { - @if(mixin-exists(hook-inverse-base-h6)) {@include hook-inverse-base-h6();} - } - - // - // Blockquotes - // - - blockquote { - @if(mixin-exists(hook-inverse-base-blockquote)) {@include hook-inverse-base-blockquote();} - } - - blockquote footer { - @if(mixin-exists(hook-inverse-base-blockquote-footer)) {@include hook-inverse-base-blockquote-footer();} - } - - // - // Horizontal rules - // - - hr, .uk-hr { - border-top-color: $inverse-base-hr-border; - @if(mixin-exists(hook-inverse-base-hr)) {@include hook-inverse-base-hr();} - } - -} -@mixin hook-breadcrumb(){} -@mixin hook-breadcrumb-item(){} -@mixin hook-breadcrumb-item-hover(){} -@mixin hook-breadcrumb-item-disabled(){} -@mixin hook-breadcrumb-item-active(){} -@mixin hook-breadcrumb-divider(){} -@mixin hook-breadcrumb-misc(){} -@mixin hook-inverse-breadcrumb-item(){} -@mixin hook-inverse-breadcrumb-item-hover(){} -@mixin hook-inverse-breadcrumb-item-disabled(){} -@mixin hook-inverse-breadcrumb-item-active(){} -@mixin hook-inverse-breadcrumb-divider(){} -@mixin hook-inverse-component-breadcrumb(){ - - .uk-breadcrumb > * > * { - color: $inverse-breadcrumb-item-color; - @if(mixin-exists(hook-inverse-breadcrumb-item)) {@include hook-inverse-breadcrumb-item();} - } - - .uk-breadcrumb > * > :hover, - .uk-breadcrumb > * > :focus { - color: $inverse-breadcrumb-item-hover-color; - @if(mixin-exists(hook-inverse-breadcrumb-item-hover)) {@include hook-inverse-breadcrumb-item-hover();} - } - - - .uk-breadcrumb > .uk-disabled > * { - @if(mixin-exists(hook-inverse-breadcrumb-item-disabled)) {@include hook-inverse-breadcrumb-item-disabled();} - } - - .uk-breadcrumb > :last-child > * { - color: $inverse-breadcrumb-item-active-color; - @if(mixin-exists(hook-inverse-breadcrumb-item-active)) {@include hook-inverse-breadcrumb-item-active();} - } - - // - // Divider - // - - .uk-breadcrumb > :nth-child(n+2):not(.uk-first-column)::before { - color: $inverse-breadcrumb-divider-color; - @if(mixin-exists(hook-inverse-breadcrumb-divider)) {@include hook-inverse-breadcrumb-divider();} - } - -} -@mixin hook-button(){} -@mixin hook-button-hover(){} -@mixin hook-button-focus(){} -@mixin hook-button-active(){} -@mixin hook-button-default(){} -@mixin hook-button-default-hover(){} -@mixin hook-button-default-active(){} -@mixin hook-button-primary(){} -@mixin hook-button-primary-hover(){} -@mixin hook-button-primary-active(){} -@mixin hook-button-secondary(){} -@mixin hook-button-secondary-hover(){} -@mixin hook-button-secondary-active(){} -@mixin hook-button-danger(){} -@mixin hook-button-danger-hover(){} -@mixin hook-button-danger-active(){} -@mixin hook-button-disabled(){} -@mixin hook-button-small(){} -@mixin hook-button-large(){} -@mixin hook-button-text(){} -@mixin hook-button-text-hover(){} -@mixin hook-button-text-disabled(){} -@mixin hook-button-link(){} -@mixin hook-button-misc(){} -@mixin hook-inverse-button-default(){} -@mixin hook-inverse-button-default-hover(){} -@mixin hook-inverse-button-default-active(){} -@mixin hook-inverse-button-primary(){} -@mixin hook-inverse-button-primary-hover(){} -@mixin hook-inverse-button-primary-active(){} -@mixin hook-inverse-button-secondary(){} -@mixin hook-inverse-button-secondary-hover(){} -@mixin hook-inverse-button-secondary-active(){} -@mixin hook-inverse-button-text(){} -@mixin hook-inverse-button-text-hover(){} -@mixin hook-inverse-button-text-disabled(){} -@mixin hook-inverse-button-link(){} -@mixin hook-inverse-component-button(){ - - // - // Default - // - - .uk-button-default { - background-color: $inverse-button-default-background; - color: $inverse-button-default-color; - @if(mixin-exists(hook-inverse-button-default)) {@include hook-inverse-button-default();} - } - - .uk-button-default:hover, - .uk-button-default:focus { - background-color: $inverse-button-default-hover-background; - color: $inverse-button-default-hover-color; - @if(mixin-exists(hook-inverse-button-default-hover)) {@include hook-inverse-button-default-hover();} - } - - .uk-button-default:active, - .uk-button-default.uk-active { - background-color: $inverse-button-default-active-background; - color: $inverse-button-default-active-color; - @if(mixin-exists(hook-inverse-button-default-active)) {@include hook-inverse-button-default-active();} - } - - // - // Primary - // - - .uk-button-primary { - background-color: $inverse-button-primary-background; - color: $inverse-button-primary-color; - @if(mixin-exists(hook-inverse-button-primary)) {@include hook-inverse-button-primary();} - } - - .uk-button-primary:hover, - .uk-button-primary:focus { - background-color: $inverse-button-primary-hover-background; - color: $inverse-button-primary-hover-color; - @if(mixin-exists(hook-inverse-button-primary-hover)) {@include hook-inverse-button-primary-hover();} - } - - .uk-button-primary:active, - .uk-button-primary.uk-active { - background-color: $inverse-button-primary-active-background; - color: $inverse-button-primary-active-color; - @if(mixin-exists(hook-inverse-button-primary-active)) {@include hook-inverse-button-primary-active();} - } - - // - // Secondary - // - - .uk-button-secondary { - background-color: $inverse-button-secondary-background; - color: $inverse-button-secondary-color; - @if(mixin-exists(hook-inverse-button-secondary)) {@include hook-inverse-button-secondary();} - } - - .uk-button-secondary:hover, - .uk-button-secondary:focus { - background-color: $inverse-button-secondary-hover-background; - color: $inverse-button-secondary-hover-color; - @if(mixin-exists(hook-inverse-button-secondary-hover)) {@include hook-inverse-button-secondary-hover();} - } - - .uk-button-secondary:active, - .uk-button-secondary.uk-active { - background-color: $inverse-button-secondary-active-background; - color: $inverse-button-secondary-active-color; - @if(mixin-exists(hook-inverse-button-secondary-active)) {@include hook-inverse-button-secondary-active();} - } - - // - // Text - // - - .uk-button-text { - color: $inverse-button-text-color; - @if(mixin-exists(hook-inverse-button-text)) {@include hook-inverse-button-text();} - } - - .uk-button-text:hover, - .uk-button-text:focus { - color: $inverse-button-text-hover-color; - @if(mixin-exists(hook-inverse-button-text-hover)) {@include hook-inverse-button-text-hover();} - } - - .uk-button-text:disabled { - color: $inverse-button-text-disabled-color; - @if(mixin-exists(hook-inverse-button-text-disabled)) {@include hook-inverse-button-text-disabled();} - } - - // - // Link - // - - .uk-button-link { - color: $inverse-button-link-color; - @if(mixin-exists(hook-inverse-button-link)) {@include hook-inverse-button-link();} - } - - .uk-button-link:hover, - .uk-button-link:focus { color: $inverse-button-link-hover-color; } - - -} -@mixin hook-card(){} -@mixin hook-card-body(){} -@mixin hook-card-header(){} -@mixin hook-card-footer(){} -@mixin hook-card-media(){} -@mixin hook-card-media-top(){} -@mixin hook-card-media-bottom(){} -@mixin hook-card-media-left(){} -@mixin hook-card-media-right(){} -@mixin hook-card-title(){} -@mixin hook-card-badge(){} -@mixin hook-card-hover(){} -@mixin hook-card-default(){} -@mixin hook-card-default-title(){} -@mixin hook-card-default-hover(){} -@mixin hook-card-default-header(){} -@mixin hook-card-default-footer(){} -@mixin hook-card-primary(){} -@mixin hook-card-primary-title(){} -@mixin hook-card-primary-hover(){} -@mixin hook-card-secondary(){} -@mixin hook-card-secondary-title(){} -@mixin hook-card-secondary-hover(){} -@mixin hook-card-misc(){} -@mixin hook-inverse-card-badge(){} -@mixin hook-inverse-component-card(){ - - &.uk-card-badge { - background-color: $inverse-card-badge-background; - color: $inverse-card-badge-color; - @if(mixin-exists(hook-inverse-card-badge)) {@include hook-inverse-card-badge();} - } - -} -@mixin hook-close(){} -@mixin hook-close-hover(){} -@mixin hook-close-misc(){} -@mixin hook-inverse-close(){} -@mixin hook-inverse-close-hover(){} -@mixin hook-inverse-component-close(){ - - .uk-close { - color: $inverse-close-color; - @if(mixin-exists(hook-inverse-close)) {@include hook-inverse-close();} - } - - .uk-close:hover, - .uk-close:focus { - color: $inverse-close-hover-color; - @if(mixin-exists(hook-inverse-close-hover)) {@include hook-inverse-close-hover();} - } - -} -@mixin hook-column-misc(){} -@mixin hook-inverse-component-column(){ - - .uk-column-divider { column-rule-color: $inverse-column-divider-rule-color; } - -} -@mixin hook-comment(){} -@mixin hook-comment-body(){} -@mixin hook-comment-header(){} -@mixin hook-comment-title(){} -@mixin hook-comment-meta(){} -@mixin hook-comment-avatar(){} -@mixin hook-comment-list-adjacent(){} -@mixin hook-comment-list-sub(){} -@mixin hook-comment-list-sub-adjacent(){} -@mixin hook-comment-primary(){} -@mixin hook-comment-misc(){} -@mixin hook-container-misc(){} -@mixin hook-countdown(){} -@mixin hook-countdown-item(){} -@mixin hook-countdown-number(){} -@mixin hook-countdown-separator(){} -@mixin hook-countdown-label(){} -@mixin hook-countdown-misc(){} -@mixin hook-inverse-countdown-item(){} -@mixin hook-inverse-countdown-number(){} -@mixin hook-inverse-countdown-separator(){} -@mixin hook-inverse-countdown-label(){} -@mixin hook-inverse-component-countdown(){ - - .uk-countdown-number, - .uk-countdown-separator { - @if(mixin-exists(hook-inverse-countdown-item)) {@include hook-inverse-countdown-item();} - } - - .uk-countdown-number { - @if(mixin-exists(hook-inverse-countdown-number)) {@include hook-inverse-countdown-number();} - } - - .uk-countdown-separator { - @if(mixin-exists(hook-inverse-countdown-separator)) {@include hook-inverse-countdown-separator();} - } - - .uk-countdown-label { - @if(mixin-exists(hook-inverse-countdown-label)) {@include hook-inverse-countdown-label();} - } - -} -@mixin hook-cover-misc(){} -@mixin hook-description-list-term(){} -@mixin hook-description-list-description(){} -@mixin hook-description-list-divider-term(){} -@mixin hook-description-list-misc(){} -@mixin svg-fill($src, $color-default, $color-new, $property: background-image){ - - $escape-color-default: escape($color-default) !default; - $escape-color-new: escape("#{$color-new}") !default; - - $data-uri: data-uri('image/svg+xml;charset=UTF-8', "#{$src}") !default; - $replace-src: replace("#{$data-uri}", "#{$escape-color-default}", "#{$escape-color-new}", "g") !default; - - #{$property}: unquote($replace-src); -} -@mixin hook-divider-icon(){} -@mixin hook-divider-icon-line(){} -@mixin hook-divider-icon-line-left(){} -@mixin hook-divider-icon-line-right(){} -@mixin hook-divider-small(){} -@mixin hook-divider-vertical(){} -@mixin hook-divider-misc(){} -@mixin hook-inverse-divider-icon(){} -@mixin hook-inverse-divider-icon-line(){} -@mixin hook-inverse-divider-small(){} -@mixin hook-inverse-divider-vertical(){} -@mixin hook-inverse-component-divider(){ - - .uk-divider-icon { - @include svg-fill($internal-divider-icon-image, "#000", $inverse-divider-icon-color); - @if(mixin-exists(hook-inverse-divider-icon)) {@include hook-inverse-divider-icon();} - } - - .uk-divider-icon::before, - .uk-divider-icon::after { - border-bottom-color: $inverse-divider-icon-line-border; - @if(mixin-exists(hook-inverse-divider-icon-line)) {@include hook-inverse-divider-icon-line();} - } - - .uk-divider-small::after { - border-top-color: $inverse-divider-small-border; - @if(mixin-exists(hook-inverse-divider-small)) {@include hook-inverse-divider-small();} - } - - .uk-divider-vertical { - border-left-color: $inverse-divider-vertical-border; - @if(mixin-exists(hook-inverse-divider-vertical)) {@include hook-inverse-divider-vertical();} - } - -} -@mixin hook-dotnav(){} -@mixin hook-dotnav-item(){} -@mixin hook-dotnav-item-hover(){} -@mixin hook-dotnav-item-onclick(){} -@mixin hook-dotnav-item-active(){} -@mixin hook-dotnav-misc(){} -@mixin hook-inverse-dotnav-item(){} -@mixin hook-inverse-dotnav-item-hover(){} -@mixin hook-inverse-dotnav-item-onclick(){} -@mixin hook-inverse-dotnav-item-active(){} -@mixin hook-inverse-component-dotnav(){ - - .uk-dotnav > * > * { - background-color: $inverse-dotnav-item-background; - @if(mixin-exists(hook-inverse-dotnav-item)) {@include hook-inverse-dotnav-item();} - } - - .uk-dotnav > * > :hover, - .uk-dotnav > * > :focus { - background-color: $inverse-dotnav-item-hover-background; - @if(mixin-exists(hook-inverse-dotnav-item-hover)) {@include hook-inverse-dotnav-item-hover();} - } - - .uk-dotnav > * > :active { - background-color: $inverse-dotnav-item-onclick-background; - @if(mixin-exists(hook-inverse-dotnav-item-onclick)) {@include hook-inverse-dotnav-item-onclick();} - } - - .uk-dotnav > .uk-active > * { - background-color: $inverse-dotnav-item-active-background; - @if(mixin-exists(hook-inverse-dotnav-item-active)) {@include hook-inverse-dotnav-item-active();} - } - -} -@mixin hook-drop-misc(){} -@mixin hook-dropdown(){} -@mixin hook-dropdown-nav(){} -@mixin hook-dropdown-nav-item(){} -@mixin hook-dropdown-nav-item-hover(){} -@mixin hook-dropdown-nav-header(){} -@mixin hook-dropdown-nav-divider(){} -@mixin hook-dropdown-misc(){} -@mixin hook-flex-misc(){} -@mixin hook-form-range(){} -@mixin hook-form-range-thumb(){} -@mixin hook-form-range-track(){} -@mixin hook-form-range-track-focus(){} -@mixin hook-form-range-misc(){} -@mixin hook-form(){} -@mixin hook-form-single-line(){} -@mixin hook-form-multi-line(){} -@mixin hook-form-focus(){} -@mixin hook-form-disabled(){} -@mixin hook-form-danger(){} -@mixin hook-form-success(){} -@mixin hook-form-blank(){} -@mixin hook-form-blank-focus(){} -@mixin hook-form-radio(){} -@mixin hook-form-radio-focus(){} -@mixin hook-form-radio-checked(){} -@mixin hook-form-radio-checked-focus(){} -@mixin hook-form-radio-disabled(){} -@mixin hook-form-legend(){} -@mixin hook-form-label(){} -@mixin hook-form-stacked-label(){} -@mixin hook-form-horizontal-label(){} -@mixin hook-form-misc(){} -@mixin hook-inverse-form(){} -@mixin hook-inverse-form-focus(){} -@mixin hook-inverse-form-radio(){} -@mixin hook-inverse-form-radio-focus(){} -@mixin hook-inverse-form-radio-checked(){} -@mixin hook-inverse-form-radio-checked-focus(){} -@mixin hook-inverse-form-label(){} -@mixin hook-inverse-component-form(){ - - .uk-input, - .uk-select, - .uk-textarea { - background-color: $inverse-form-background; - color: $inverse-form-color; - background-clip: padding-box; - @if(mixin-exists(hook-inverse-form)) {@include hook-inverse-form();} - - &:focus { - background-color: $inverse-form-focus-background; - color: $inverse-form-focus-color; - @if(mixin-exists(hook-inverse-form-focus)) {@include hook-inverse-form-focus();} - } - } - - // - // Placeholder - // - - .uk-input::-ms-input-placeholder { color: $inverse-form-placeholder-color !important; } - .uk-input::placeholder { color: $inverse-form-placeholder-color; } - - .uk-textarea::-ms-input-placeholder { color: $inverse-form-placeholder-color !important; } - .uk-textarea::placeholder { color: $inverse-form-placeholder-color; } - - // - // Select - // - - .uk-select:not([multiple]):not([size]) { @include svg-fill($internal-form-select-image, "#000", $inverse-form-select-icon-color); } - - // - // Datalist - // - - .uk-input[list]:hover, - .uk-input[list]:focus { @include svg-fill($internal-form-datalist-image, "#000", $inverse-form-datalist-icon-color); } - - // - // Radio and checkbox - // - - .uk-radio, - .uk-checkbox { - background-color: $inverse-form-radio-background; - @if(mixin-exists(hook-inverse-form-radio)) {@include hook-inverse-form-radio();} - } - - // Focus - .uk-radio:focus, - .uk-checkbox:focus { - background-color: $inverse-form-radio-focus-background; - @if(mixin-exists(hook-inverse-form-radio-focus)) {@include hook-inverse-form-radio-focus();} - } - - // Checked - .uk-radio:checked, - .uk-checkbox:checked, - .uk-checkbox:indeterminate { - background-color: $inverse-form-radio-checked-background; - @if(mixin-exists(hook-inverse-form-radio-checked)) {@include hook-inverse-form-radio-checked();} - } - - // Focus - .uk-radio:checked:focus, - .uk-checkbox:checked:focus, - .uk-checkbox:indeterminate:focus { - background-color: $inverse-form-radio-checked-focus-background; - @if(mixin-exists(hook-inverse-form-radio-checked-focus)) {@include hook-inverse-form-radio-checked-focus();} - } - - // Icon - .uk-radio:checked { @include svg-fill($internal-form-radio-image, "#000", $inverse-form-radio-checked-icon-color); } - .uk-checkbox:checked { @include svg-fill($internal-form-checkbox-image, "#000", $inverse-form-radio-checked-icon-color); } - .uk-checkbox:indeterminate { @include svg-fill($internal-form-checkbox-indeterminate-image, "#000", $inverse-form-radio-checked-icon-color); } - - // Label - .uk-form-label { - @if(mixin-exists(hook-inverse-form-label)) {@include hook-inverse-form-label();} - } - - // Icon - .uk-form-icon { color: $inverse-form-icon-color; } - .uk-form-icon:hover { color: $inverse-form-icon-hover-color; } - -} -@mixin hook-grid-divider-horizontal(){} -@mixin hook-grid-divider-vertical(){} -@mixin hook-grid-misc(){} -@mixin hook-inverse-grid-divider-horizontal(){} -@mixin hook-inverse-grid-divider-vertical(){} -@mixin hook-inverse-component-grid(){ - - .uk-grid-divider > :not(.uk-first-column)::before { - border-left-color: $inverse-grid-divider-border; - @if(mixin-exists(hook-inverse-grid-divider-horizontal)) {@include hook-inverse-grid-divider-horizontal();} - } - - .uk-grid-divider.uk-grid-stack > .uk-grid-margin::before { - border-top-color: $inverse-grid-divider-border; - @if(mixin-exists(hook-inverse-grid-divider-vertical)) {@include hook-inverse-grid-divider-vertical();} - } - -} -@mixin hook-heading-small(){} -@mixin hook-heading-medium(){} -@mixin hook-heading-large(){} -@mixin hook-heading-xlarge(){} -@mixin hook-heading-2xlarge(){} -@mixin hook-heading-primary(){} -@mixin hook-heading-hero(){} -@mixin hook-heading-divider(){} -@mixin hook-heading-bullet(){} -@mixin hook-heading-line(){} -@mixin hook-heading-misc(){} -@mixin hook-inverse-heading-small(){} -@mixin hook-inverse-heading-medium(){} -@mixin hook-inverse-heading-large(){} -@mixin hook-inverse-heading-xlarge(){} -@mixin hook-inverse-heading-2xlarge(){} -@mixin hook-inverse-heading-primary(){} -@mixin hook-inverse-heading-hero(){} -@mixin hook-inverse-heading-divider(){} -@mixin hook-inverse-heading-bullet(){} -@mixin hook-inverse-heading-line(){} -@mixin hook-inverse-component-heading(){ - - .uk-heading-small { - @if(mixin-exists(hook-inverse-heading-small)) {@include hook-inverse-heading-small();} - } - - .uk-heading-medium { - @if(mixin-exists(hook-inverse-heading-medium)) {@include hook-inverse-heading-medium();} - } - - .uk-heading-large { - @if(mixin-exists(hook-inverse-heading-large)) {@include hook-inverse-heading-large();} - } - - .uk-heading-xlarge { - @if(mixin-exists(hook-inverse-heading-xlarge)) {@include hook-inverse-heading-xlarge();} - } - - .uk-heading-2xlarge { - @if(mixin-exists(hook-inverse-heading-2xlarge)) {@include hook-inverse-heading-2xlarge();} - } - - @if ($deprecated == true) { .uk-heading-primary { @if (mixin-exists(hook-inverse-heading-primary)) {@include hook-inverse-heading-primary();}}} - - @if ($deprecated == true) { .uk-heading-hero { @if (mixin-exists(hook-inverse-heading-hero)) {@include hook-inverse-heading-hero();}}} - - .uk-heading-divider { - border-bottom-color: $inverse-heading-divider-border; - @if(mixin-exists(hook-inverse-heading-divider)) {@include hook-inverse-heading-divider();} - } - - .uk-heading-bullet::before { - border-left-color: $inverse-heading-bullet-border; - @if(mixin-exists(hook-inverse-heading-bullet)) {@include hook-inverse-heading-bullet();} - } - - .uk-heading-line > ::before, - .uk-heading-line > ::after { - border-bottom-color: $inverse-heading-line-border; - @if(mixin-exists(hook-inverse-heading-line)) {@include hook-inverse-heading-line();} - } - -} -@mixin hook-height-misc(){} -@mixin hook-icon-link(){} -@mixin hook-icon-link-hover(){} -@mixin hook-icon-link-active(){} -@mixin hook-icon-button(){} -@mixin hook-icon-button-hover(){} -@mixin hook-icon-button-active(){} -@mixin hook-icon-misc(){} -@mixin hook-inverse-icon-link(){} -@mixin hook-inverse-icon-link-hover(){} -@mixin hook-inverse-icon-link-active(){} -@mixin hook-inverse-icon-button(){} -@mixin hook-inverse-icon-button-hover(){} -@mixin hook-inverse-icon-button-active(){} -@mixin hook-inverse-component-icon(){ - - // - // Link - // - - .uk-icon-link { - color: $inverse-icon-link-color; - @if(mixin-exists(hook-inverse-icon-link)) {@include hook-inverse-icon-link();} - } - - .uk-icon-link:hover, - .uk-icon-link:focus { - color: $inverse-icon-link-hover-color; - @if(mixin-exists(hook-inverse-icon-link-hover)) {@include hook-inverse-icon-link-hover();} - } - - .uk-icon-link:active, - .uk-active > .uk-icon-link { - color: $inverse-icon-link-active-color; - @if(mixin-exists(hook-inverse-icon-link-active)) {@include hook-inverse-icon-link-active();} - } - - // - // Button - // - - .uk-icon-button { - background-color: $inverse-icon-button-background; - color: $inverse-icon-button-color; - @if(mixin-exists(hook-inverse-icon-button)) {@include hook-inverse-icon-button();} - } - - .uk-icon-button:hover, - .uk-icon-button:focus { - background-color: $inverse-icon-button-hover-background; - color: $inverse-icon-button-hover-color; - @if(mixin-exists(hook-inverse-icon-button-hover)) {@include hook-inverse-icon-button-hover();} - } - - .uk-icon-button:active { - background-color: $inverse-icon-button-active-background; - color: $inverse-icon-button-active-color; - @if(mixin-exists(hook-inverse-icon-button-active)) {@include hook-inverse-icon-button-active();} - } - -} -@mixin hook-iconnav(){} -@mixin hook-iconnav-item(){} -@mixin hook-iconnav-item-hover(){} -@mixin hook-iconnav-item-active(){} -@mixin hook-iconnav-misc(){} -@mixin hook-inverse-iconnav-item(){} -@mixin hook-inverse-iconnav-item-hover(){} -@mixin hook-inverse-iconnav-item-active(){} -@mixin hook-inverse-component-iconnav(){ - - .uk-iconnav > * > a { - color: $inverse-iconnav-item-color; - @if(mixin-exists(hook-inverse-iconnav-item)) {@include hook-inverse-iconnav-item();} - } - - .uk-iconnav > * > a:hover, - .uk-iconnav > * > a:focus { - color: $inverse-iconnav-item-hover-color; - @if(mixin-exists(hook-inverse-iconnav-item-hover)) {@include hook-inverse-iconnav-item-hover();} - } - - .uk-iconnav > .uk-active > a { - color: $inverse-iconnav-item-active-color; - @if(mixin-exists(hook-inverse-iconnav-item-active)) {@include hook-inverse-iconnav-item-active();} - } - -} -@mixin hook-inverse-component-link(){ - - a.uk-link-muted, - .uk-link-muted a { - color: $inverse-link-muted-color; - @if(mixin-exists(hook-inverse-link-muted)) {@include hook-inverse-link-muted();} - } - - a.uk-link-muted:hover, - .uk-link-muted a:hover, - .uk-link-toggle:hover .uk-link-muted, - .uk-link-toggle:focus .uk-link-muted { - color: $inverse-link-muted-hover-color; - @if(mixin-exists(hook-inverse-link-muted-hover)) {@include hook-inverse-link-muted-hover();} - } - - a.uk-link-text:hover, - .uk-link-text a:hover, - .uk-link-toggle:hover .uk-link-text, - .uk-link-toggle:focus .uk-link-text { - color: $inverse-link-text-hover-color; - @if(mixin-exists(hook-inverse-link-text-hover)) {@include hook-inverse-link-text-hover();} - } - - a.uk-link-heading:hover, - .uk-link-heading a:hover, - .uk-link-toggle:hover .uk-link-heading, - .uk-link-toggle:focus .uk-link-heading { - color: $inverse-link-heading-hover-color; - @if(mixin-exists(hook-inverse-link-heading-hover)) {@include hook-inverse-link-heading-hover();} - } - -} -@mixin hook-inverse-component-list(){ - - .uk-list-muted > ::before { color: $inverse-list-muted-color !important; } - .uk-list-emphasis > ::before { color: $inverse-list-emphasis-color !important; } - .uk-list-primary > ::before { color: $inverse-list-primary-color !important; } - .uk-list-secondary > ::before { color: $inverse-list-secondary-color !important; } - - .uk-list-bullet > ::before { - @include svg-fill($internal-list-bullet-image, "#000", $inverse-list-bullet-icon-color); - } - - .uk-list-divider > :nth-child(n+2) { - border-top-color: $inverse-list-divider-border; - @if(mixin-exists(hook-inverse-list-divider)) {@include hook-inverse-list-divider();} - } - - .uk-list-striped > * { - @if(mixin-exists(hook-inverse-list-striped)) {@include hook-inverse-list-striped();} - } - - .uk-list-striped > :nth-of-type(odd) { background-color: $inverse-list-striped-background; } - -} -@mixin hook-inverse-component-totop(){ - - .uk-totop { - color: $inverse-totop-color; - @if(mixin-exists(hook-inverse-totop)) {@include hook-inverse-totop();} - } - - .uk-totop:hover, - .uk-totop:focus { - color: $inverse-totop-hover-color; - @if(mixin-exists(hook-inverse-totop-hover)) {@include hook-inverse-totop-hover();} - } - - .uk-totop:active { - color: $inverse-totop-active-color; - @if(mixin-exists(hook-inverse-totop-active)) {@include hook-inverse-totop-active();} - } - -} -@mixin hook-inverse-component-label(){ - - .uk-label { - background-color: $inverse-label-background; - color: $inverse-label-color; - @if(mixin-exists(hook-inverse-label)) {@include hook-inverse-label();} - } - -} -@mixin hook-inverse-component-search(){ - - // - // Input - // - - .uk-search-input { color: $inverse-search-color; } - - .uk-search-input:-ms-input-placeholder { color: $inverse-search-placeholder-color !important; } - .uk-search-input::placeholder { color: $inverse-search-placeholder-color; } - - - // - // Icon - // - - .uk-search .uk-search-icon { color: $inverse-search-icon-color; } - .uk-search .uk-search-icon:hover { color: $inverse-search-icon-color; } - - // - // Style modifier - // - - .uk-search-default .uk-search-input { - background-color: $inverse-search-default-background; - @if(mixin-exists(hook-inverse-search-default-input)) {@include hook-inverse-search-default-input();} - } - - .uk-search-default .uk-search-input:focus { - background-color: $inverse-search-default-focus-background; - @if(mixin-exists(hook-inverse-search-default-input-focus)) {@include hook-inverse-search-default-input-focus();} - } - - .uk-search-navbar .uk-search-input { - background-color: $inverse-search-navbar-background; - @if(mixin-exists(hook-inverse-search-navbar-input)) {@include hook-inverse-search-navbar-input();} - } - - .uk-search-large .uk-search-input { - background-color: $inverse-search-large-background; - @if(mixin-exists(hook-inverse-search-large-input)) {@include hook-inverse-search-large-input();} - } - - // - // Toggle - // - - .uk-search-toggle { - color: $inverse-search-toggle-color; - @if(mixin-exists(hook-inverse-search-toggle)) {@include hook-inverse-search-toggle();} - } - - .uk-search-toggle:hover, - .uk-search-toggle:focus { - color: $inverse-search-toggle-hover-color; - @if(mixin-exists(hook-inverse-search-toggle-hover)) {@include hook-inverse-search-toggle-hover();} - } - -} -@mixin hook-inverse-component-nav(){ - - // - // Parent icon modifier - // - - .uk-nav-parent-icon > .uk-parent > a::after { - @include svg-fill($internal-nav-parent-close-image, "#000", $inverse-nav-parent-icon-color); - @if(mixin-exists(hook-inverse-nav-parent-icon)) {@include hook-inverse-nav-parent-icon();} - } - - .uk-nav-parent-icon > .uk-parent.uk-open > a::after { @include svg-fill($internal-nav-parent-open-image, "#000", $inverse-nav-parent-icon-color); } - - // - // Default - // - - .uk-nav-default > li > a { - color: $inverse-nav-default-item-color; - @if(mixin-exists(hook-inverse-nav-default-item)) {@include hook-inverse-nav-default-item();} - } - - .uk-nav-default > li > a:hover, - .uk-nav-default > li > a:focus { - color: $inverse-nav-default-item-hover-color; - @if(mixin-exists(hook-inverse-nav-default-item-hover)) {@include hook-inverse-nav-default-item-hover();} - } - - .uk-nav-default > li.uk-active > a { - color: $inverse-nav-default-item-active-color; - @if(mixin-exists(hook-inverse-nav-default-item-active)) {@include hook-inverse-nav-default-item-active();} - } - - .uk-nav-default .uk-nav-header { - color: $inverse-nav-default-header-color; - @if(mixin-exists(hook-inverse-nav-default-header)) {@include hook-inverse-nav-default-header();} - } - - .uk-nav-default .uk-nav-divider { - border-top-color: $inverse-nav-default-divider-border; - @if(mixin-exists(hook-inverse-nav-default-divider)) {@include hook-inverse-nav-default-divider();} - } - - .uk-nav-default .uk-nav-sub a { color: $inverse-nav-default-sublist-item-color; } - - .uk-nav-default .uk-nav-sub a:hover, - .uk-nav-default .uk-nav-sub a:focus { color: $inverse-nav-default-sublist-item-hover-color; } - - .uk-nav-default .uk-nav-sub li.uk-active > a { color: $inverse-nav-default-sublist-item-active-color; } - - // - // Primary - // - - .uk-nav-primary > li > a { - color: $inverse-nav-primary-item-color; - @if(mixin-exists(hook-inverse-nav-primary-item)) {@include hook-inverse-nav-primary-item();} - } - - .uk-nav-primary > li > a:hover, - .uk-nav-primary > li > a:focus { - color: $inverse-nav-primary-item-hover-color; - @if(mixin-exists(hook-inverse-nav-primary-item-hover)) {@include hook-inverse-nav-primary-item-hover();} - } - - .uk-nav-primary > li.uk-active > a { - color: $inverse-nav-primary-item-active-color; - @if(mixin-exists(hook-inverse-nav-primary-item-active)) {@include hook-inverse-nav-primary-item-active();} - } - - .uk-nav-primary .uk-nav-header { - color: $inverse-nav-primary-header-color; - @if(mixin-exists(hook-inverse-nav-primary-header)) {@include hook-inverse-nav-primary-header();} - } - - .uk-nav-primary .uk-nav-divider { - border-top-color: $inverse-nav-primary-divider-border; - @if(mixin-exists(hook-inverse-nav-primary-divider)) {@include hook-inverse-nav-primary-divider();} - } - - .uk-nav-primary .uk-nav-sub a { color: $inverse-nav-primary-sublist-item-color; } - - .uk-nav-primary .uk-nav-sub a:hover, - .uk-nav-primary .uk-nav-sub a:focus { color: $inverse-nav-primary-sublist-item-hover-color; } - - .uk-nav-primary .uk-nav-sub li.uk-active > a { color: $inverse-nav-primary-sublist-item-active-color; } - - // - // Dividers - // - - .uk-nav.uk-nav-divider > :not(.uk-nav-divider) + :not(.uk-nav-header, .uk-nav-divider) { - border-top-color: $inverse-nav-dividers-border; - @if(mixin-exists(hook-nav-dividers)) {@include hook-nav-dividers();} - } - -} -@mixin hook-inverse-component-navbar(){ - - .uk-navbar-nav > li > a { - color: $inverse-navbar-nav-item-color; - @if(mixin-exists(hook-inverse-navbar-nav-item)) {@include hook-inverse-navbar-nav-item();} - } - - .uk-navbar-nav > li:hover > a, - .uk-navbar-nav > li > a:focus, - .uk-navbar-nav > li > a.uk-open { - color: $inverse-navbar-nav-item-hover-color; - @if(mixin-exists(hook-inverse-navbar-nav-item-hover)) {@include hook-inverse-navbar-nav-item-hover();} - } - - .uk-navbar-nav > li > a:active { - color: $inverse-navbar-nav-item-onclick-color; - @if(mixin-exists(hook-inverse-navbar-nav-item-onclick)) {@include hook-inverse-navbar-nav-item-onclick();} - } - - .uk-navbar-nav > li.uk-active > a { - color: $inverse-navbar-nav-item-active-color; - @if(mixin-exists(hook-inverse-navbar-nav-item-active)) {@include hook-inverse-navbar-nav-item-active();} - } - - .uk-navbar-item { - color: $inverse-navbar-item-color; - @if(mixin-exists(hook-inverse-navbar-item)) {@include hook-inverse-navbar-item();} - } - - .uk-navbar-toggle { - color: $inverse-navbar-toggle-color; - @if(mixin-exists(hook-inverse-navbar-toggle)) {@include hook-inverse-navbar-toggle();} - } - - .uk-navbar-toggle:hover, - .uk-navbar-toggle:focus, - .uk-navbar-toggle.uk-open { - color: $inverse-navbar-toggle-hover-color; - @if(mixin-exists(hook-inverse-navbar-toggle-hover)) {@include hook-inverse-navbar-toggle-hover();} - } - -} -@mixin hook-inverse-component-subnav(){ - - .uk-subnav > * > :first-child { - color: $inverse-subnav-item-color; - @if(mixin-exists(hook-inverse-subnav-item)) {@include hook-inverse-subnav-item();} - } - - .uk-subnav > * > a:hover, - .uk-subnav > * > a:focus { - color: $inverse-subnav-item-hover-color; - @if(mixin-exists(hook-inverse-subnav-item-hover)) {@include hook-inverse-subnav-item-hover();} - } - - .uk-subnav > .uk-active > a { - color: $inverse-subnav-item-active-color; - @if(mixin-exists(hook-inverse-subnav-item-active)) {@include hook-inverse-subnav-item-active();} - } - - // - // Divider - // - - .uk-subnav-divider > :nth-child(n+2):not(.uk-first-column)::before { - border-left-color: $inverse-subnav-divider-border; - @if(mixin-exists(hook-inverse-subnav-divider)) {@include hook-inverse-subnav-divider();} - } - - // - // Pill - // - - .uk-subnav-pill > * > :first-child { - background-color: $inverse-subnav-pill-item-background; - color: $inverse-subnav-pill-item-color; - @if(mixin-exists(hook-inverse-subnav-pill-item)) {@include hook-inverse-subnav-pill-item();} - } - - .uk-subnav-pill > * > a:hover, - .uk-subnav-pill > * > a:focus { - background-color: $inverse-subnav-pill-item-hover-background; - color: $inverse-subnav-pill-item-hover-color; - @if(mixin-exists(hook-inverse-subnav-pill-item-hover)) {@include hook-inverse-subnav-pill-item-hover();} - } - - .uk-subnav-pill > * > a:active { - background-color: $inverse-subnav-pill-item-onclick-background; - color: $inverse-subnav-pill-item-onclick-color; - @if(mixin-exists(hook-inverse-subnav-pill-item-onclick)) {@include hook-inverse-subnav-pill-item-onclick();} - } - - .uk-subnav-pill > .uk-active > a { - background-color: $inverse-subnav-pill-item-active-background; - color: $inverse-subnav-pill-item-active-color; - @if(mixin-exists(hook-inverse-subnav-pill-item-active)) {@include hook-inverse-subnav-pill-item-active();} - } - - // - // Disabled - // - - .uk-subnav > .uk-disabled > a { - color: $inverse-subnav-item-disabled-color; - @if(mixin-exists(hook-inverse-subnav-item-disabled)) {@include hook-inverse-subnav-item-disabled();} - } - -} -@mixin hook-inverse-component-pagination(){ - - .uk-pagination > * > * { - color: $inverse-pagination-item-color; - @if(mixin-exists(hook-inverse-pagination-item)) {@include hook-inverse-pagination-item();} - } - - .uk-pagination > * > :hover, - .uk-pagination > * > :focus { - color: $inverse-pagination-item-hover-color; - @if(mixin-exists(hook-inverse-pagination-item-hover)) {@include hook-inverse-pagination-item-hover();} - } - - .uk-pagination > .uk-active > * { - color: $inverse-pagination-item-active-color; - @if(mixin-exists(hook-inverse-pagination-item-active)) {@include hook-inverse-pagination-item-active();} - } - - .uk-pagination > .uk-disabled > * { - color: $inverse-pagination-item-disabled-color; - @if(mixin-exists(hook-inverse-pagination-item-disabled)) {@include hook-inverse-pagination-item-disabled();} - } - -} -@mixin hook-inverse-component-tab(){ - - .uk-tab { - @if(mixin-exists(hook-inverse-tab)) {@include hook-inverse-tab();} - } - - .uk-tab > * > a { - color: $inverse-tab-item-color; - @if(mixin-exists(hook-inverse-tab-item)) {@include hook-inverse-tab-item();} - } - - .uk-tab > * > a:hover, - .uk-tab > * > a:focus{ - color: $inverse-tab-item-hover-color; - @if(mixin-exists(hook-inverse-tab-item-hover)) {@include hook-inverse-tab-item-hover();} - } - - .uk-tab > .uk-active > a { - color: $inverse-tab-item-active-color; - @if(mixin-exists(hook-inverse-tab-item-active)) {@include hook-inverse-tab-item-active();} - } - - .uk-tab > .uk-disabled > a { - color: $inverse-tab-item-disabled-color; - @if(mixin-exists(hook-inverse-tab-item-disabled)) {@include hook-inverse-tab-item-disabled();} - } - -} -@mixin hook-inverse-component-slidenav(){ - - .uk-slidenav { - color: $inverse-slidenav-color; - @if(mixin-exists(hook-inverse-slidenav)) {@include hook-inverse-slidenav();} - } - - .uk-slidenav:hover, - .uk-slidenav:focus { - color: $inverse-slidenav-hover-color; - @if(mixin-exists(hook-inverse-slidenav-hover)) {@include hook-inverse-slidenav-hover();} - } - - .uk-slidenav:active { - color: $inverse-slidenav-active-color; - @if(mixin-exists(hook-inverse-slidenav-active)) {@include hook-inverse-slidenav-active();} - } - -} -@mixin hook-inverse-component-text(){ - - .uk-text-lead { - color: $inverse-text-lead-color; - @if(mixin-exists(hook-inverse-text-lead)) {@include hook-inverse-text-lead();} - } - - .uk-text-meta { - color: $inverse-text-meta-color; - @if(mixin-exists(hook-inverse-text-meta)) {@include hook-inverse-text-meta();} - } - - .uk-text-muted { color: $inverse-text-muted-color !important; } - .uk-text-emphasis { color: $inverse-text-emphasis-color !important; } - .uk-text-primary { color: $inverse-text-primary-color !important; } - .uk-text-secondary { color: $inverse-text-secondary-color !important; } - -} -@mixin hook-inverse-component-utility(){ - - .uk-dropcap::first-letter, - .uk-dropcap p:first-of-type::first-letter { - @if(mixin-exists(hook-inverse-dropcap)) {@include hook-inverse-dropcap();} - } - - .uk-logo { - color: $inverse-logo-color; - @if(mixin-exists(hook-inverse-logo)) {@include hook-inverse-logo();} - } - - .uk-logo:hover, - .uk-logo:focus { - color: $inverse-logo-hover-color; - @if(mixin-exists(hook-inverse-logo-hover)) {@include hook-inverse-logo-hover();} - } - - .uk-logo > :not(.uk-logo-inverse):not(:only-of-type) { display: none; } - .uk-logo-inverse { display: inline; } - -} -@mixin hook-inverse(){ - @include hook-inverse-component-base(); - @include hook-inverse-component-link(); - @include hook-inverse-component-heading(); - @include hook-inverse-component-divider(); - @include hook-inverse-component-list(); - @include hook-inverse-component-icon(); - @include hook-inverse-component-form(); - @include hook-inverse-component-button(); - @include hook-inverse-component-grid(); - @include hook-inverse-component-close(); - @include hook-inverse-component-totop(); - @include hook-inverse-component-badge(); - @include hook-inverse-component-label(); - @include hook-inverse-component-article(); - @include hook-inverse-component-search(); - @include hook-inverse-component-nav(); - @include hook-inverse-component-navbar(); - @include hook-inverse-component-subnav(); - @include hook-inverse-component-breadcrumb(); - @include hook-inverse-component-pagination(); - @include hook-inverse-component-tab(); - @include hook-inverse-component-slidenav(); - @include hook-inverse-component-dotnav(); - @include hook-inverse-component-accordion(); - @include hook-inverse-component-iconnav(); - @include hook-inverse-component-text(); - @include hook-inverse-component-column(); - @include hook-inverse-component-utility(); -} -@mixin hook-label(){} -@mixin hook-label-success(){} -@mixin hook-label-warning(){} -@mixin hook-label-danger(){} -@mixin hook-label-misc(){} -@mixin hook-inverse-label(){} -@mixin hook-leader(){} -@mixin hook-leader-misc(){} -@mixin hook-inverse-leader(){} -@mixin hook-inverse-component-leader(){ - - .uk-leader-fill::after { - @if(mixin-exists(hook-inverse-leader)) {@include hook-inverse-leader();} - } - -} -@mixin hook-lightbox(){} -@mixin hook-lightbox-item(){} -@mixin hook-lightbox-toolbar(){} -@mixin hook-lightbox-toolbar-icon(){} -@mixin hook-lightbox-toolbar-icon-hover(){} -@mixin hook-lightbox-button(){} -@mixin hook-lightbox-button-hover(){} -@mixin hook-lightbox-button-active(){} -@mixin hook-lightbox-misc(){} -@mixin hook-link-muted(){} -@mixin hook-link-muted-hover(){} -@mixin hook-link-text(){} -@mixin hook-link-text-hover(){} -@mixin hook-link-heading(){} -@mixin hook-link-heading-hover(){} -@mixin hook-link-reset(){} -@mixin hook-link-misc(){} -@mixin hook-inverse-link-muted(){} -@mixin hook-inverse-link-muted-hover(){} -@mixin hook-inverse-link-text-hover(){} -@mixin hook-inverse-link-heading-hover(){} -@mixin hook-list-divider(){} -@mixin hook-list-striped(){} -@mixin hook-list-misc(){} -@mixin hook-inverse-list-divider(){} -@mixin hook-inverse-list-striped(){} -@mixin hook-margin-misc(){} -@mixin hook-marker(){} -@mixin hook-marker-hover(){} -@mixin hook-marker-misc(){} -@mixin hook-inverse-marker(){} -@mixin hook-inverse-marker-hover(){} -@mixin hook-inverse-component-marker(){ - - .uk-marker { - background: $inverse-marker-background; - color: $inverse-marker-color; - @if(mixin-exists(hook-inverse-marker)) {@include hook-inverse-marker();} - } - - .uk-marker:hover, - .uk-marker:focus { - color: $inverse-marker-hover-color; - @if(mixin-exists(hook-inverse-marker-hover)) {@include hook-inverse-marker-hover();} - } - -} -@mixin hook-modal(){} -@mixin hook-modal-dialog(){} -@mixin hook-modal-full(){} -@mixin hook-modal-body(){} -@mixin hook-modal-header(){} -@mixin hook-modal-footer(){} -@mixin hook-modal-title(){} -@mixin hook-modal-close(){} -@mixin hook-modal-close-hover(){} -@mixin hook-modal-close-default(){} -@mixin hook-modal-close-default-hover(){} -@mixin hook-modal-close-outside(){} -@mixin hook-modal-close-outside-hover(){} -@mixin hook-modal-close-full(){} -@mixin hook-modal-close-full-hover(){} -@mixin hook-modal-misc(){} -@mixin hook-nav-sub(){} -@mixin hook-nav-parent-icon(){} -@mixin hook-nav-header(){} -@mixin hook-nav-divider(){} -@mixin hook-nav-default(){} -@mixin hook-nav-default-item(){} -@mixin hook-nav-default-item-hover(){} -@mixin hook-nav-default-item-active(){} -@mixin hook-nav-default-header(){} -@mixin hook-nav-default-divider(){} -@mixin hook-nav-primary(){} -@mixin hook-nav-primary-item(){} -@mixin hook-nav-primary-item-hover(){} -@mixin hook-nav-primary-item-active(){} -@mixin hook-nav-primary-header(){} -@mixin hook-nav-primary-divider(){} -@mixin hook-nav-dividers(){} -@mixin hook-nav-misc(){} -@mixin hook-inverse-nav-parent-icon(){} -@mixin hook-inverse-nav-default-item(){} -@mixin hook-inverse-nav-default-item-hover(){} -@mixin hook-inverse-nav-default-item-active(){} -@mixin hook-inverse-nav-default-header(){} -@mixin hook-inverse-nav-default-divider(){} -@mixin hook-inverse-nav-primary-item(){} -@mixin hook-inverse-nav-primary-item-hover(){} -@mixin hook-inverse-nav-primary-item-active(){} -@mixin hook-inverse-nav-primary-header(){} -@mixin hook-inverse-nav-primary-divider(){} -@mixin hook-navbar(){} -@mixin hook-navbar-container(){} -@mixin hook-navbar-nav-item(){} -@mixin hook-navbar-nav-item-hover(){} -@mixin hook-navbar-nav-item-onclick(){} -@mixin hook-navbar-nav-item-active(){} -@mixin hook-navbar-item(){} -@mixin hook-navbar-toggle(){} -@mixin hook-navbar-toggle-hover(){} -@mixin hook-navbar-toggle-icon(){} -@mixin hook-navbar-toggle-icon-hover(){} -@mixin hook-navbar-subtitle(){} -@mixin hook-navbar-primary(){} -@mixin hook-navbar-transparent(){} -@mixin hook-navbar-sticky(){} -@mixin hook-navbar-dropdown(){} -@mixin hook-navbar-dropdown-dropbar(){} -@mixin hook-navbar-dropdown-nav(){} -@mixin hook-navbar-dropdown-nav-item(){} -@mixin hook-navbar-dropdown-nav-item-hover(){} -@mixin hook-navbar-dropdown-nav-item-active(){} -@mixin hook-navbar-dropdown-nav-header(){} -@mixin hook-navbar-dropdown-nav-divider(){} -@mixin hook-navbar-dropbar(){} -@mixin hook-navbar-dropbar-slide(){} -@mixin hook-navbar-misc(){} -@mixin hook-inverse-navbar-nav-item(){} -@mixin hook-inverse-navbar-nav-item-hover(){} -@mixin hook-inverse-navbar-nav-item-onclick(){} -@mixin hook-inverse-navbar-nav-item-active(){} -@mixin hook-inverse-navbar-item(){} -@mixin hook-inverse-navbar-toggle(){} -@mixin hook-inverse-navbar-toggle-hover(){} -@mixin hook-notification(){} -@mixin hook-notification-message(){} -@mixin hook-notification-close(){} -@mixin hook-notification-message-primary(){} -@mixin hook-notification-message-success(){} -@mixin hook-notification-message-warning(){} -@mixin hook-notification-message-danger(){} -@mixin hook-notification-misc(){} -@mixin hook-offcanvas-bar(){} -@mixin hook-offcanvas-close(){} -@mixin hook-offcanvas-overlay(){} -@mixin hook-offcanvas-misc(){} -@mixin hook-overlay(){} -@mixin hook-overlay-icon(){} -@mixin hook-overlay-default(){} -@mixin hook-overlay-primary(){} -@mixin hook-overlay-misc(){} -@mixin hook-padding-misc(){} -@mixin hook-pagination(){} -@mixin hook-pagination-item(){} -@mixin hook-pagination-item-hover(){} -@mixin hook-pagination-item-active(){} -@mixin hook-pagination-item-disabled(){} -@mixin hook-pagination-misc(){} -@mixin hook-inverse-pagination-item(){} -@mixin hook-inverse-pagination-item-hover(){} -@mixin hook-inverse-pagination-item-active(){} -@mixin hook-inverse-pagination-item-disabled(){} -@mixin hook-placeholder(){} -@mixin hook-placeholder-misc(){} -@mixin hook-position-misc(){} -@mixin hook-print(){} -@mixin hook-progress(){} -@mixin hook-progress-bar(){} -@mixin hook-progress-misc(){} -@mixin hook-search-input(){} -@mixin hook-search-default-input(){} -@mixin hook-search-default-input-focus(){} -@mixin hook-search-navbar-input(){} -@mixin hook-search-large-input(){} -@mixin hook-search-toggle(){} -@mixin hook-search-toggle-hover(){} -@mixin hook-search-misc(){} -@mixin hook-inverse-search-default-input(){} -@mixin hook-inverse-search-default-input-focus(){} -@mixin hook-inverse-search-navbar-input(){} -@mixin hook-inverse-search-large-input(){} -@mixin hook-inverse-search-toggle(){} -@mixin hook-inverse-search-toggle-hover(){} -@mixin hook-section(){} -@mixin hook-section-default(){} -@mixin hook-section-muted(){} -@mixin hook-section-primary(){} -@mixin hook-section-secondary(){} -@mixin hook-section-overlap(){} -@mixin hook-section-misc(){} -@mixin hook-slidenav(){} -@mixin hook-slidenav-hover(){} -@mixin hook-slidenav-active(){} -@mixin hook-slidenav-previous(){} -@mixin hook-slidenav-next(){} -@mixin hook-slidenav-large(){} -@mixin hook-slidenav-container(){} -@mixin hook-slidenav-misc(){} -@mixin hook-inverse-slidenav(){} -@mixin hook-inverse-slidenav-hover(){} -@mixin hook-inverse-slidenav-active(){} -@mixin hook-slider(){} -@mixin hook-slider-misc(){} -@mixin hook-slideshow(){} -@mixin hook-slideshow-misc(){} -@mixin hook-sortable(){} -@mixin hook-sortable-drag(){} -@mixin hook-sortable-placeholder(){} -@mixin hook-sortable-empty(){} -@mixin hook-sortable-misc(){} -@mixin hook-spinner(){} -@mixin hook-spinner-misc(){} -@mixin hook-sticky-misc(){} -@mixin hook-subnav(){} -@mixin hook-subnav-item(){} -@mixin hook-subnav-item-hover(){} -@mixin hook-subnav-item-active(){} -@mixin hook-subnav-divider(){} -@mixin hook-subnav-pill-item(){} -@mixin hook-subnav-pill-item-hover(){} -@mixin hook-subnav-pill-item-onclick(){} -@mixin hook-subnav-pill-item-active(){} -@mixin hook-subnav-item-disabled(){} -@mixin hook-subnav-misc(){} -@mixin hook-inverse-subnav-item(){} -@mixin hook-inverse-subnav-item-hover(){} -@mixin hook-inverse-subnav-item-active(){} -@mixin hook-inverse-subnav-divider(){} -@mixin hook-inverse-subnav-pill-item(){} -@mixin hook-inverse-subnav-pill-item-hover(){} -@mixin hook-inverse-subnav-pill-item-onclick(){} -@mixin hook-inverse-subnav-pill-item-active(){} -@mixin hook-inverse-subnav-item-disabled(){} -@mixin hook-svg-misc(){} -@mixin hook-switcher-misc(){} -@mixin hook-tab(){} -@mixin hook-tab-item(){} -@mixin hook-tab-item-hover(){} -@mixin hook-tab-item-active(){} -@mixin hook-tab-item-disabled(){} -@mixin hook-tab-bottom(){} -@mixin hook-tab-bottom-item(){} -@mixin hook-tab-left(){} -@mixin hook-tab-right(){} -@mixin hook-tab-left-item(){} -@mixin hook-tab-right-item(){} -@mixin hook-tab-misc(){} -@mixin hook-inverse-tab(){} -@mixin hook-inverse-tab-item(){} -@mixin hook-inverse-tab-item-hover(){} -@mixin hook-inverse-tab-item-active(){} -@mixin hook-inverse-tab-item-disabled(){} -@mixin hook-table(){} -@mixin hook-table-header-cell(){} -@mixin hook-table-cell(){} -@mixin hook-table-footer(){} -@mixin hook-table-caption(){} -@mixin hook-table-divider(){} -@mixin hook-table-striped(){} -@mixin hook-table-hover(){} -@mixin hook-table-row-active(){} -@mixin hook-table-small(){} -@mixin hook-table-large(){} -@mixin hook-table-misc(){} -@mixin hook-inverse-table-header-cell(){} -@mixin hook-inverse-table-caption(){} -@mixin hook-inverse-table-row-active(){} -@mixin hook-inverse-table-divider(){} -@mixin hook-inverse-table-striped(){} -@mixin hook-inverse-table-hover(){} -@mixin hook-inverse-component-table(){ - - .uk-table th { - color: $inverse-table-header-cell-color; - @if(mixin-exists(hook-inverse-table-header-cell)) {@include hook-inverse-table-header-cell();} - } - - .uk-table caption { - color: $inverse-table-caption-color; - @if(mixin-exists(hook-inverse-table-caption)) {@include hook-inverse-table-caption();} - } - - .uk-table > tr.uk-active, - .uk-table tbody tr.uk-active { - background: $inverse-table-row-active-background; - @if(mixin-exists(hook-inverse-table-row-active)) {@include hook-inverse-table-row-active();} - } - - .uk-table-divider > tr:not(:first-child), - .uk-table-divider > :not(:first-child) > tr, - .uk-table-divider > :first-child > tr:not(:first-child) { - border-top-color: $inverse-table-divider-border; - @if(mixin-exists(hook-inverse-table-divider)) {@include hook-inverse-table-divider();} - } - - .uk-table-striped > tr:nth-of-type(odd), - .uk-table-striped tbody tr:nth-of-type(odd) { - background: $inverse-table-striped-row-background; - @if(mixin-exists(hook-inverse-table-striped)) {@include hook-inverse-table-striped();} - } - - .uk-table-hover > tr:hover, - .uk-table-hover tbody tr:hover { - background: $inverse-table-hover-row-background; - @if(mixin-exists(hook-inverse-table-hover)) {@include hook-inverse-table-hover();} - } - -} -@mixin hook-text-lead(){} -@mixin hook-text-meta(){} -@mixin hook-text-small(){} -@mixin hook-text-large(){} -@mixin hook-text-background(){} -@mixin hook-text-misc(){} -@mixin hook-inverse-text-lead(){} -@mixin hook-inverse-text-meta(){} -@mixin hook-thumbnav(){} -@mixin hook-thumbnav-item(){} -@mixin hook-thumbnav-item-hover(){} -@mixin hook-thumbnav-item-active(){} -@mixin hook-thumbnav-misc(){} -@mixin hook-inverse-thumbnav-item(){} -@mixin hook-inverse-thumbnav-item-hover(){} -@mixin hook-inverse-thumbnav-item-active(){} -@mixin hook-inverse-component-thumbnav(){ - - .uk-thumbnav > * > * { - @if(mixin-exists(hook-inverse-thumbnav-item)) {@include hook-inverse-thumbnav-item();} - } - - .uk-thumbnav > * > :hover, - .uk-thumbnav > * > :focus { - @if(mixin-exists(hook-inverse-thumbnav-item-hover)) {@include hook-inverse-thumbnav-item-hover();} - } - - .uk-thumbnav > .uk-active > * { - @if(mixin-exists(hook-inverse-thumbnav-item-active)) {@include hook-inverse-thumbnav-item-active();} - } - -} -@mixin hook-tile(){} -@mixin hook-tile-default(){} -@mixin hook-tile-muted(){} -@mixin hook-tile-primary(){} -@mixin hook-tile-secondary(){} -@mixin hook-tile-misc(){} -@mixin hook-tooltip(){} -@mixin hook-tooltip-misc(){} -@mixin hook-totop(){} -@mixin hook-totop-hover(){} -@mixin hook-totop-active(){} -@mixin hook-totop-misc(){} -@mixin hook-inverse-totop(){} -@mixin hook-inverse-totop-hover(){} -@mixin hook-inverse-totop-active(){} -@mixin hook-transition-misc(){} -@mixin hook-panel-scrollable(){} -@mixin hook-box-shadow-bottom(){} -@mixin hook-dropcap(){} -@mixin hook-logo(){} -@mixin hook-logo-hover(){} -@mixin hook-utility-misc(){} -@mixin hook-inverse-dropcap(){} -@mixin hook-inverse-logo(){} -@mixin hook-inverse-logo-hover(){} -@mixin hook-visibility-misc(){} -@mixin hook-width-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/_import.scss b/docs/_sass/uikit/theme/_import.scss deleted file mode 100644 index ab4a71d491..0000000000 --- a/docs/_sass/uikit/theme/_import.scss +++ /dev/null @@ -1,81 +0,0 @@ -// Base -@import "https://github.com/variables"; -@import "https://github.com/base"; - -// Elements -@import "https://github.com/link"; -@import "https://github.com/heading"; -@import "https://github.com/divider"; -@import "https://github.com/list"; -@import "https://github.com/description-list"; -@import "https://github.com/table"; -@import "https://github.com/icon"; -@import "https://github.com/form-range"; -@import "https://github.com/form"; -@import "https://github.com/button"; -@import "https://github.com/progress"; - -// Layout -@import "https://github.com/section"; -@import "https://github.com/container"; -@import "https://github.com/tile"; -@import "https://github.com/card"; - -// Common -@import "https://github.com/close"; -@import "https://github.com/spinner"; -@import "https://github.com/marker"; -@import "https://github.com/totop"; -@import "https://github.com/alert"; -@import "https://github.com/placeholder"; -@import "https://github.com/badge"; -@import "https://github.com/label"; -@import "https://github.com/overlay"; -@import "https://github.com/article"; -@import "https://github.com/comment"; -@import "https://github.com/search"; - -// JavaScript -@import "https://github.com/accordion"; -@import "https://github.com/drop"; -@import "https://github.com/dropdown"; -@import "https://github.com/modal"; -@import "https://github.com/slider"; -@import "https://github.com/sticky"; -@import "https://github.com/offcanvas"; -@import "https://github.com/leader"; -@import "https://github.com/notification"; -@import "https://github.com/tooltip"; -@import "https://github.com/sortable"; -@import "https://github.com/countdown"; - -@import "https://github.com/grid"; - -// Navs -@import "https://github.com/nav"; -@import "https://github.com/navbar"; -@import "https://github.com/subnav"; -@import "https://github.com/breadcrumb"; -@import "https://github.com/pagination"; -@import "https://github.com/tab"; -@import "https://github.com/slidenav"; -@import "https://github.com/dotnav"; -@import "https://github.com/thumbnav"; -@import "https://github.com/iconnav"; - -@import "https://github.com/lightbox"; - -// Utilities -@import "https://github.com/animation"; -@import "https://github.com/width"; -@import "https://github.com/height"; -@import "https://github.com/text"; -@import "https://github.com/column"; -@import "https://github.com/background"; -@import "https://github.com/align"; -@import "https://github.com/utility"; -@import "https://github.com/margin"; -@import "https://github.com/padding"; -@import "https://github.com/position"; -@import "https://github.com/transition"; -@import "https://github.com/inverse"; diff --git a/docs/_sass/uikit/theme/accordion.scss b/docs/_sass/uikit/theme/accordion.scss deleted file mode 100644 index ae25a64afe..0000000000 --- a/docs/_sass/uikit/theme/accordion.scss +++ /dev/null @@ -1,59 +0,0 @@ -// -// Component: Accordion -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$accordion-icon-margin-left: 10px !default; -$accordion-icon-color: $global-color !default; -$internal-accordion-open-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2213%22%20height%3D%2213%22%20viewBox%3D%220%200%2013%2013%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Crect%20fill%3D%22#000%22%20width%3D%2213%22%20height%3D%221%22%20x%3D%220%22%20y%3D%226%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$internal-accordion-close-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2213%22%20height%3D%2213%22%20viewBox%3D%220%200%2013%2013%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Crect%20fill%3D%22#000%22%20width%3D%2213%22%20height%3D%221%22%20x%3D%220%22%20y%3D%226%22%20%2F%3E%0A%20%20%20%20%3Crect%20fill%3D%22#000%22%20width%3D%221%22%20height%3D%2213%22%20x%3D%226%22%20y%3D%220%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; - - -// Component -// ======================================================================== - -// @mixin hook-accordion(){} - - -// Item -// ======================================================================== - -// @mixin hook-accordion-item(){} - - -// Title -// ======================================================================== - - - -// @mixin hook-accordion-title-hover(){} - - -// Content -// ======================================================================== - -// @mixin hook-accordion-content(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-accordion-misc(){} - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-accordion-item(){} - -// @mixin hook-inverse-accordion-title(){} -// @mixin hook-inverse-accordion-title-hover(){} - - diff --git a/docs/_sass/uikit/theme/alert.scss b/docs/_sass/uikit/theme/alert.scss deleted file mode 100644 index c4baa7ca90..0000000000 --- a/docs/_sass/uikit/theme/alert.scss +++ /dev/null @@ -1,46 +0,0 @@ -// -// Component: Alert -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$alert-close-opacity: 0.4 !default; -$alert-close-hover-opacity: 0.8 !default; - - -// Component -// ======================================================================== - -// @mixin hook-alert(){} - - -// Close -// ======================================================================== - - - - - - -// Style modifiers -// ======================================================================== - -// @mixin hook-alert-primary(){} - -// @mixin hook-alert-success(){} - -// @mixin hook-alert-warning(){} - -// @mixin hook-alert-danger(){} - - -// Miscellaneous -// ======================================================================== - diff --git a/docs/_sass/uikit/theme/align.scss b/docs/_sass/uikit/theme/align.scss deleted file mode 100644 index 290abd4115..0000000000 --- a/docs/_sass/uikit/theme/align.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Align -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-align-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/animation.scss b/docs/_sass/uikit/theme/animation.scss deleted file mode 100644 index 03ebbc6eae..0000000000 --- a/docs/_sass/uikit/theme/animation.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Animation -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-animation-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/article.scss b/docs/_sass/uikit/theme/article.scss deleted file mode 100644 index a698e3ed18..0000000000 --- a/docs/_sass/uikit/theme/article.scss +++ /dev/null @@ -1,51 +0,0 @@ -// -// Component: Article -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$article-meta-link-color: $article-meta-color !default; -$article-meta-link-hover-color: $global-color !default; - - -// Component -// ======================================================================== - -// @mixin hook-article(){} - - -// Adjacent sibling -// ======================================================================== - -// @mixin hook-article-adjacent(){} - - -// Title -// ======================================================================== - -// @mixin hook-article-title(){} - - -// Meta -// ======================================================================== - - - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-article-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-article-meta(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/background.scss b/docs/_sass/uikit/theme/background.scss deleted file mode 100644 index 29e062e9d2..0000000000 --- a/docs/_sass/uikit/theme/background.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Background -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-background-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/badge.scss b/docs/_sass/uikit/theme/badge.scss deleted file mode 100644 index 22ae937122..0000000000 --- a/docs/_sass/uikit/theme/badge.scss +++ /dev/null @@ -1,29 +0,0 @@ -// -// Component: Badge -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-badge(){} - -// @mixin hook-badge-hover(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-badge-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-badge(){} -// @mixin hook-inverse-badge-hover(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/base.scss b/docs/_sass/uikit/theme/base.scss deleted file mode 100644 index 2c1c33569e..0000000000 --- a/docs/_sass/uikit/theme/base.scss +++ /dev/null @@ -1,116 +0,0 @@ -// -// Component: Base -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$base-code-padding-horizontal: 6px !default; -$base-code-padding-vertical: 2px !default; -$base-code-background: $global-muted-background !default; - -$base-blockquote-color: $global-emphasis-color !default; - -$base-blockquote-footer-color: $global-color !default; - -$base-pre-padding: 10px !default; -$base-pre-background: $global-background !default; -$base-pre-border-width: $global-border-width !default; -$base-pre-border: $global-border !default; -$base-pre-border-radius: 3px !default; - - -// Body -// ======================================================================== - -// @mixin hook-base-body(){} - - -// Links -// ======================================================================== - -// @mixin hook-base-link(){} - -// @mixin hook-base-link-hover(){} - - -// Text-level semantics -// ======================================================================== - - - - -// Headings -// ======================================================================== - -// @mixin hook-base-heading(){} - -// @mixin hook-base-h1(){} - -// @mixin hook-base-h2(){} - -// @mixin hook-base-h3(){} - -// @mixin hook-base-h4(){} - -// @mixin hook-base-h5(){} - -// @mixin hook-base-h6(){} - - -// Horizontal rules -// ======================================================================== - -// @mixin hook-base-hr(){} - - -// Blockquotes -// ======================================================================== - - - - - - -// Preformatted text -// ======================================================================== - - - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-base-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-base-blockquote-color: $inverse-global-emphasis-color !default; -$inverse-base-blockquote-footer-color: $inverse-global-color !default; - -// @mixin hook-inverse-base-link(){} -// @mixin hook-inverse-base-link-hover(){} - - - -// @mixin hook-inverse-base-heading(){} - -// @mixin hook-inverse-base-h1(){} -// @mixin hook-inverse-base-h2(){} -// @mixin hook-inverse-base-h3(){} -// @mixin hook-inverse-base-h4(){} -// @mixin hook-inverse-base-h5(){} -// @mixin hook-inverse-base-h6(){} - - - - -// @mixin hook-inverse-base-hr(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/breadcrumb.scss b/docs/_sass/uikit/theme/breadcrumb.scss deleted file mode 100644 index 40c04e5d9e..0000000000 --- a/docs/_sass/uikit/theme/breadcrumb.scss +++ /dev/null @@ -1,45 +0,0 @@ -// -// Component: Breadcrumb -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-breadcrumb(){} - - -// Items -// ======================================================================== - -// @mixin hook-breadcrumb-item(){} - -// @mixin hook-breadcrumb-item-hover(){} - -// @mixin hook-breadcrumb-item-disabled(){} - -// @mixin hook-breadcrumb-item-active(){} - -// @mixin hook-breadcrumb-divider(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-breadcrumb-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-breadcrumb-item(){} -// @mixin hook-inverse-breadcrumb-item-hover(){} -// @mixin hook-inverse-breadcrumb-item-disabled(){} -// @mixin hook-inverse-breadcrumb-item-active(){} - -// @mixin hook-inverse-breadcrumb-divider(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/button.scss b/docs/_sass/uikit/theme/button.scss deleted file mode 100644 index d78c38933f..0000000000 --- a/docs/_sass/uikit/theme/button.scss +++ /dev/null @@ -1,159 +0,0 @@ -// -// Component: Button -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$button-line-height: $global-control-height - ($button-border-width * 2) !default; -$button-small-line-height: $global-control-small-height - ($button-border-width * 2) !default; -$button-large-line-height: $global-control-large-height - ($button-border-width * 2) !default; - -$button-font-size: $global-small-font-size !default; -$button-large-font-size: $global-small-font-size !default; - -$button-default-background: transparent !default; -$button-default-hover-background: transparent !default; -$button-default-active-background: transparent !default; - -$button-disabled-background: transparent !default; - -$button-text-hover-color: $global-emphasis-color !default; - -// -// New -// - -$button-text-transform: uppercase !default; - -$button-border-width: $global-border-width !default; - -$button-default-border: $global-border !default; -$button-default-hover-border: darken($global-border, 20%) !default; -$button-default-active-border: darken($global-border, 30%) !default; - -$button-disabled-border: $global-border !default; - -$button-text-border-width: $global-border-width !default; -$button-text-border: $button-text-hover-color !default; - - -// Component -// ======================================================================== - - - -// @mixin hook-button-hover(){} - -// @mixin hook-button-focus(){} - -// @mixin hook-button-active(){} - - -// Style modifiers -// ======================================================================== - - - - - - - -// -// Primary -// - - - -// @mixin hook-button-primary-hover(){} - -// @mixin hook-button-primary-active(){} - -// -// Secondary -// - - - -// @mixin hook-button-secondary-hover(){} - -// @mixin hook-button-secondary-active(){} - -// -// Danger -// - - - -// @mixin hook-button-danger-hover(){} - -// @mixin hook-button-danger-active(){} - - -// Disabled -// ======================================================================== - - - - -// Size modifiers -// ======================================================================== - -// @mixin hook-button-small(){} - -// @mixin hook-button-large(){} - - -// Text modifier -// ======================================================================== - - - - - - - - -// Link modifier -// ======================================================================== - -// @mixin hook-button-link(){} - - -// Miscellaneous -// ======================================================================== - - - - -// Inverse -// ======================================================================== - -$inverse-button-default-background: transparent !default; -$inverse-button-default-color: $inverse-global-emphasis-color !default; -$inverse-button-default-hover-background: transparent !default; -$inverse-button-default-hover-color: $inverse-global-emphasis-color !default; -$inverse-button-default-active-background: transparent !default; -$inverse-button-default-active-color: $inverse-global-emphasis-color !default; - -$inverse-button-text-hover-color: $inverse-global-emphasis-color !default; - - - - - -// @mixin hook-inverse-button-primary(){} -// @mixin hook-inverse-button-primary-hover(){} -// @mixin hook-inverse-button-primary-active(){} - -// @mixin hook-inverse-button-secondary(){} -// @mixin hook-inverse-button-secondary-hover(){} -// @mixin hook-inverse-button-secondary-active(){} - - -// @mixin hook-inverse-button-text-hover(){} -// @mixin hook-inverse-button-text-disabled(){} - -// @mixin hook-inverse-button-link(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/card.scss b/docs/_sass/uikit/theme/card.scss deleted file mode 100644 index 7eb4deee98..0000000000 --- a/docs/_sass/uikit/theme/card.scss +++ /dev/null @@ -1,128 +0,0 @@ -// -// Component: Card -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$card-hover-background: $global-background !default; - -$card-default-background: $global-background !default; -$card-default-hover-background: $card-default-background !default; - -$card-primary-hover-background: $card-primary-background !default; - -$card-secondary-hover-background: $card-secondary-background !default; - -// -// New -// - -$card-badge-border-radius: 2px !default; -$card-badge-text-transform: uppercase !default; - -$card-hover-box-shadow: $global-large-box-shadow !default; - -$card-default-box-shadow: $global-medium-box-shadow !default; -$card-default-hover-box-shadow: $global-large-box-shadow !default; - -$card-default-header-border-width: $global-border-width !default; -$card-default-header-border: $global-border !default; - -$card-default-footer-border-width: $global-border-width !default; -$card-default-footer-border: $global-border !default; - -$card-primary-box-shadow: $global-medium-box-shadow !default; -$card-primary-hover-box-shadow: $global-large-box-shadow !default; - -$card-secondary-box-shadow: $global-medium-box-shadow !default; -$card-secondary-hover-box-shadow: $global-large-box-shadow !default; - - -// Component -// ======================================================================== - - - - -// Sections -// ======================================================================== - -// @mixin hook-card-body(){} - -// @mixin hook-card-header(){} - -// @mixin hook-card-footer(){} - - -// Media -// ======================================================================== - -// @mixin hook-card-media(){} - -// @mixin hook-card-media-top(){} - -// @mixin hook-card-media-bottom(){} - -// @mixin hook-card-media-left(){} - -// @mixin hook-card-media-right(){} - - -// Title -// ======================================================================== - -// @mixin hook-card-title(){} - - -// Badge -// ======================================================================== - - - - -// Hover modifier -// ======================================================================== - - - - -// Style modifiers -// ======================================================================== - - - -// @mixin hook-card-default-title(){} - - - - - - - -// -// Primary -// - - - -// @mixin hook-card-primary-title(){} - - - -// -// Secondary -// - - - -// @mixin hook-card-secondary-title(){} - - - - -// Miscellaneous -// ======================================================================== - diff --git a/docs/_sass/uikit/theme/close.scss b/docs/_sass/uikit/theme/close.scss deleted file mode 100644 index f0762942f8..0000000000 --- a/docs/_sass/uikit/theme/close.scss +++ /dev/null @@ -1,29 +0,0 @@ -// -// Component: Close -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - - - -// @mixin hook-close-hover(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-close-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-close(){} -// @mixin hook-inverse-close-hover(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/column.scss b/docs/_sass/uikit/theme/column.scss deleted file mode 100644 index 80be850572..0000000000 --- a/docs/_sass/uikit/theme/column.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Column -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-column-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/comment.scss b/docs/_sass/uikit/theme/comment.scss deleted file mode 100644 index a486c59142..0000000000 --- a/docs/_sass/uikit/theme/comment.scss +++ /dev/null @@ -1,69 +0,0 @@ -// -// Component: Comment -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$comment-primary-padding: $global-gutter !default; -$comment-primary-background: $global-muted-background !default; - - -// Component -// ======================================================================== - -// @mixin hook-comment(){} - - -// Sections -// ======================================================================== - -// @mixin hook-comment-body(){} - -// @mixin hook-comment-header(){} - - -// Title -// ======================================================================== - -// @mixin hook-comment-title(){} - - -// Meta -// ======================================================================== - -// @mixin hook-comment-meta(){} - - -// Avatar -// ======================================================================== - -// @mixin hook-comment-avatar(){} - - -// List -// ======================================================================== - -// @mixin hook-comment-list-adjacent(){} - -// @mixin hook-comment-list-sub(){} - -// @mixin hook-comment-list-sub-adjacent(){} - - -// Style modifier -// ======================================================================== - - - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-comment-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/container.scss b/docs/_sass/uikit/theme/container.scss deleted file mode 100644 index ba77ded72e..0000000000 --- a/docs/_sass/uikit/theme/container.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Container -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-container-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/countdown.scss b/docs/_sass/uikit/theme/countdown.scss deleted file mode 100644 index 01f1761ca3..0000000000 --- a/docs/_sass/uikit/theme/countdown.scss +++ /dev/null @@ -1,53 +0,0 @@ -// -// Component: Countdown -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-countdown(){} - - -// Item -// ======================================================================== - -// @mixin hook-countdown-item(){} - - -// Number -// ======================================================================== - -// @mixin hook-countdown-number(){} - - -// Separator -// ======================================================================== - -// @mixin hook-countdown-separator(){} - - -// Label -// ======================================================================== - -// @mixin hook-countdown-label(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-countdown-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-countdown-item(){} -// @mixin hook-inverse-countdown-number(){} -// @mixin hook-inverse-countdown-separator(){} -// @mixin hook-inverse-countdown-label(){} diff --git a/docs/_sass/uikit/theme/description-list.scss b/docs/_sass/uikit/theme/description-list.scss deleted file mode 100644 index 8f836d6321..0000000000 --- a/docs/_sass/uikit/theme/description-list.scss +++ /dev/null @@ -1,32 +0,0 @@ -// -// Component: Description list -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$description-list-term-font-size: $global-small-font-size !default; -$description-list-term-font-weight: normal !default; -$description-list-term-text-transform: uppercase !default; - - -// Component -// ======================================================================== - - - -// @mixin hook-description-list-description(){} - - -// Style modifier -// ======================================================================== - -// @mixin hook-description-list-divider-term(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-description-list-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/divider.scss b/docs/_sass/uikit/theme/divider.scss deleted file mode 100644 index 1273b2a9ec..0000000000 --- a/docs/_sass/uikit/theme/divider.scss +++ /dev/null @@ -1,49 +0,0 @@ -// -// Component: Divider -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Icon -// ======================================================================== - -// @mixin hook-divider-icon(){} - -// @mixin hook-divider-icon-line(){} - -// @mixin hook-divider-icon-line-left(){} - -// @mixin hook-divider-icon-line-right(){} - - -// Small -// ======================================================================== - -// @mixin hook-divider-small(){} - - -// Vertical -// ======================================================================== - -// @mixin hook-divider-vertical(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-divider-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-divider-icon(){} -// @mixin hook-inverse-divider-icon-line(){} - -// @mixin hook-inverse-divider-small(){} - -// @mixin hook-inverse-divider-vertical(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/dotnav.scss b/docs/_sass/uikit/theme/dotnav.scss deleted file mode 100644 index 1bc835970f..0000000000 --- a/docs/_sass/uikit/theme/dotnav.scss +++ /dev/null @@ -1,52 +0,0 @@ -// -// Component: Dotnav -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$dotnav-item-background: transparent !default; - -// -// New -// - -$dotnav-item-border-width: 1px !default; - -$dotnav-item-border: rgba($global-color, 0.4) !default; -$dotnav-item-hover-border: transparent !default; -$dotnav-item-onclick-border: transparent !default; -$dotnav-item-active-border: transparent !default; - - -// Component -// ======================================================================== - -// @mixin hook-dotnav(){} - - - - - - - - - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-dotnav-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-dotnav-item-background: transparent !default; - -// @mixin hook-inverse-dotnav(){} - - - diff --git a/docs/_sass/uikit/theme/drop.scss b/docs/_sass/uikit/theme/drop.scss deleted file mode 100644 index 6940984871..0000000000 --- a/docs/_sass/uikit/theme/drop.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Drop -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-drop-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/dropdown.scss b/docs/_sass/uikit/theme/dropdown.scss deleted file mode 100644 index c5aa02ef44..0000000000 --- a/docs/_sass/uikit/theme/dropdown.scss +++ /dev/null @@ -1,45 +0,0 @@ -// -// Component: Dropdown -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$dropdown-padding: 25px !default; -$dropdown-background: $global-background !default; - -// -// New -// - -$dropdown-nav-font-size: $global-small-font-size !default; - -$dropdown-box-shadow: 0 5px 12px rgba(0,0,0,0.15) !default; - - -// Component -// ======================================================================== - - - - -// Nav -// ======================================================================== - - - -// @mixin hook-dropdown-nav-item(){} - -// @mixin hook-dropdown-nav-item-hover(){} - -// @mixin hook-dropdown-nav-header(){} - -// @mixin hook-dropdown-nav-divider(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-dropdown-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/form-range.scss b/docs/_sass/uikit/theme/form-range.scss deleted file mode 100644 index ca424f30e3..0000000000 --- a/docs/_sass/uikit/theme/form-range.scss +++ /dev/null @@ -1,45 +0,0 @@ -// -// Component: Form Range -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$form-range-thumb-background: $global-background !default; - -// -// New -// - -$form-range-thumb-border-width: $global-border-width !default; -$form-range-thumb-border: darken($global-border, 10%) !default; - -$form-range-track-border-radius: 500px !default; - - -// Component -// ======================================================================== - -// @mixin hook-form-range(){} - - -// Thumb -// ======================================================================== - - - - -// Track -// ======================================================================== - - - -// @mixin hook-form-range-track-focus(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-form-range-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/form.scss b/docs/_sass/uikit/theme/form.scss deleted file mode 100644 index ef80695839..0000000000 --- a/docs/_sass/uikit/theme/form.scss +++ /dev/null @@ -1,131 +0,0 @@ -// -// Component: Form -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$form-line-height: $form-height - (2* $form-border-width) !default; - -$form-background: $global-background !default; -$form-focus-background: $global-background !default; - -$form-small-line-height: $form-small-height - (2* $form-border-width) !default; -$form-large-line-height: $form-large-height - (2* $form-border-width) !default; - -$form-radio-background: transparent !default; - -$form-stacked-margin-bottom: 5px !default; - -// -// New -// - -$form-border-width: $global-border-width !default; -$form-border: $global-border !default; - -$form-focus-border: $global-primary-background !default; - -$form-disabled-border: $global-border !default; - -$form-danger-border: $global-danger-background !default; -$form-success-border: $global-success-background !default; - -$form-blank-focus-border: $global-border !default; -$form-blank-focus-border-style: dashed !default; - -$form-radio-border-width: $global-border-width !default; -$form-radio-border: darken($global-border, 10%) !default; - -$form-radio-focus-border: $global-primary-background !default; - -$form-radio-checked-border: transparent !default; - -$form-radio-disabled-border: $global-border !default; - -$form-label-color: $global-emphasis-color !default; -$form-label-font-size: $global-small-font-size !default; - - -// Component -// ======================================================================== - - - -// @mixin hook-form-single-line(){} - -// @mixin hook-form-multi-line(){} - - - - - - -// Style modifiers -// ======================================================================== - - - - - - - - - - -// Radio and checkbox -// ======================================================================== - - - - - - - -// @mixin hook-form-radio-checked-focus(){} - - - - -// Legend -// ======================================================================== - -// @mixin hook-form-legend(){} - - -// Label -// ======================================================================== - - - - -// Layout -// ======================================================================== - -// @mixin hook-form-stacked-label(){} - -// @mixin hook-form-horizontal-label(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-form-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-form-label-color: $inverse-global-emphasis-color !default; - - - - - - - - -// @mixin hook-inverse-form-radio-checked-focus(){} - diff --git a/docs/_sass/uikit/theme/grid.scss b/docs/_sass/uikit/theme/grid.scss deleted file mode 100644 index 1b3779f809..0000000000 --- a/docs/_sass/uikit/theme/grid.scss +++ /dev/null @@ -1,28 +0,0 @@ -// -// Component: Grid -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Divider -// ======================================================================== - -// @mixin hook-grid-divider-horizontal(){} -// @mixin hook-grid-divider-vertical(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-grid-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-grid-divider-horizontal(){} -// @mixin hook-inverse-grid-divider-vertical(){} diff --git a/docs/_sass/uikit/theme/heading.scss b/docs/_sass/uikit/theme/heading.scss deleted file mode 100644 index 9ec067428a..0000000000 --- a/docs/_sass/uikit/theme/heading.scss +++ /dev/null @@ -1,67 +0,0 @@ -// -// Component: Heading -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-heading-small(){} - -// @mixin hook-heading-medium(){} - -// @mixin hook-heading-large(){} - -// @mixin hook-heading-xlarge(){} - -// @mixin hook-heading-2xlarge(){} - - -// Divider -// ======================================================================== - -// @mixin hook-heading-divider(){} - - -// Bullet -// ======================================================================== - -// @mixin hook-heading-bullet(){} - - -// Line -// ======================================================================== - -// @mixin hook-heading-line(){} - - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-heading-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-heading-small(){} - -// @mixin hook-inverse-heading-medium(){} - -// @mixin hook-inverse-heading-large(){} - -// @mixin hook-inverse-heading-xlarge(){} - -// @mixin hook-inverse-heading-2xlarge(){} - -// @mixin hook-inverse-heading-divider(){} - -// @mixin hook-inverse-heading-bullet(){} - -// @mixin hook-inverse-heading-line(){} diff --git a/docs/_sass/uikit/theme/height.scss b/docs/_sass/uikit/theme/height.scss deleted file mode 100644 index 37f2c2f84b..0000000000 --- a/docs/_sass/uikit/theme/height.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Height -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-height-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/icon.scss b/docs/_sass/uikit/theme/icon.scss deleted file mode 100644 index b81c79abd8..0000000000 --- a/docs/_sass/uikit/theme/icon.scss +++ /dev/null @@ -1,50 +0,0 @@ -// -// Component: Icon -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Style modifiers -// ======================================================================== - -// -// Link -// - -// @mixin hook-icon-link(){} - -// @mixin hook-icon-link-hover(){} - -// @mixin hook-icon-link-active(){} - -// -// Button -// - - - -// @mixin hook-icon-button-hover(){} - -// @mixin hook-icon-button-active(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-icon-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-icon-link(){} -// @mixin hook-inverse-icon-link-hover(){} -// @mixin hook-inverse-icon-link-active(){} - -// @mixin hook-inverse-icon-button(){} -// @mixin hook-inverse-icon-button-hover(){} -// @mixin hook-inverse-icon-button-active(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/iconnav.scss b/docs/_sass/uikit/theme/iconnav.scss deleted file mode 100644 index 376a0afabf..0000000000 --- a/docs/_sass/uikit/theme/iconnav.scss +++ /dev/null @@ -1,40 +0,0 @@ -// -// Component: Iconnav -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$subnav-item-font-size: $global-small-font-size !default; - - -// Component -// ======================================================================== - -// @mixin hook-iconnav(){} - - - -// @mixin hook-iconnav-item-hover(){} - -// @mixin hook-iconnav-item-active(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-iconnav-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-iconnav-item(){} -// @mixin hook-inverse-iconnav-item-hover(){} -// @mixin hook-inverse-iconnav-item-active(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/inverse.scss b/docs/_sass/uikit/theme/inverse.scss deleted file mode 100644 index 75a5a3b168..0000000000 --- a/docs/_sass/uikit/theme/inverse.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Inverse -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-inverse(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/label.scss b/docs/_sass/uikit/theme/label.scss deleted file mode 100644 index ff09ac92cf..0000000000 --- a/docs/_sass/uikit/theme/label.scss +++ /dev/null @@ -1,43 +0,0 @@ -// -// Component: Label -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$label-border-radius: 2px !default; -$label-text-transform: uppercase !default; - - -// Component -// ======================================================================== - - - - -// Color modifiers -// ======================================================================== - -// @mixin hook-label-success(){} - -// @mixin hook-label-warning(){} - -// @mixin hook-label-danger(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-label-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-label(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/leader.scss b/docs/_sass/uikit/theme/leader.scss deleted file mode 100644 index 6618325e82..0000000000 --- a/docs/_sass/uikit/theme/leader.scss +++ /dev/null @@ -1,26 +0,0 @@ -// -// Component: Leader -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-leader(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-leader-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-leader(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/lightbox.scss b/docs/_sass/uikit/theme/lightbox.scss deleted file mode 100644 index 6c9d115ab1..0000000000 --- a/docs/_sass/uikit/theme/lightbox.scss +++ /dev/null @@ -1,50 +0,0 @@ -// -// Component: Lightbox -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-lightbox(){} - - -// Item -// ======================================================================== - -// @mixin hook-lightbox-item(){} - - -// Toolbar -// ======================================================================== - -// @mixin hook-lightbox-toolbar(){} - - -// Toolbar Icon -// ======================================================================== - -// @mixin hook-lightbox-toolbar-icon(){} - -// @mixin hook-lightbox-toolbar-icon-hover(){} - - -// Button -// ======================================================================== - -// @mixin hook-lightbox-button(){} - -// @mixin hook-lightbox-button-hover(){} - -// @mixin hook-lightbox-button-active(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-lightbox-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/link.scss b/docs/_sass/uikit/theme/link.scss deleted file mode 100644 index 0658b58a6d..0000000000 --- a/docs/_sass/uikit/theme/link.scss +++ /dev/null @@ -1,55 +0,0 @@ -// -// Component: Link -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Muted -// ======================================================================== - -// @mixin hook-link-muted(){} - -// @mixin hook-link-muted-hover(){} - - -// Text -// ======================================================================== - -// @mixin hook-link-text(){} - -// @mixin hook-link-text-hover(){} - - -// Heading -// ======================================================================== - -// @mixin hook-link-heading(){} - -// @mixin hook-link-heading-hover(){} - - -// Reset -// ======================================================================== - -// @mixin hook-link-reset(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-link-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-link-muted(){} -// @mixin hook-inverse-link-muted-hover(){} - -// @mixin hook-inverse-link-text-hover(){} - -// @mixin hook-inverse-link-heading-hover(){} diff --git a/docs/_sass/uikit/theme/list.scss b/docs/_sass/uikit/theme/list.scss deleted file mode 100644 index 33804fa0eb..0000000000 --- a/docs/_sass/uikit/theme/list.scss +++ /dev/null @@ -1,36 +0,0 @@ -// -// Component: List -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$list-striped-border-width: $global-border-width !default; -$list-striped-border: $global-border !default; - - -// Style modifiers -// ======================================================================== - -// @mixin hook-list-divider(){} - - - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-list-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-list-divider(){} - diff --git a/docs/_sass/uikit/theme/margin.scss b/docs/_sass/uikit/theme/margin.scss deleted file mode 100644 index a2cdb5ec99..0000000000 --- a/docs/_sass/uikit/theme/margin.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Margin -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-margin-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/marker.scss b/docs/_sass/uikit/theme/marker.scss deleted file mode 100644 index 1e4fd5f379..0000000000 --- a/docs/_sass/uikit/theme/marker.scss +++ /dev/null @@ -1,29 +0,0 @@ -// -// Component: Marker -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - - - -// @mixin hook-marker-hover(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-marker-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-marker(){} -// @mixin hook-inverse-marker-hover(){} diff --git a/docs/_sass/uikit/theme/modal.scss b/docs/_sass/uikit/theme/modal.scss deleted file mode 100644 index adc2135808..0000000000 --- a/docs/_sass/uikit/theme/modal.scss +++ /dev/null @@ -1,84 +0,0 @@ -// -// Component: Modal -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$modal-header-background: $modal-dialog-background !default; -$modal-footer-background: $modal-dialog-background !default; - -// -// New -// - -$modal-header-border-width: $global-border-width !default; -$modal-header-border: $global-border !default; - -$modal-footer-border-width: $global-border-width !default; -$modal-footer-border: $global-border !default; - -$modal-close-full-padding: $global-margin !default; -$modal-close-full-background: $modal-dialog-background !default; - - -// Component -// ======================================================================== - -// @mixin hook-modal(){} - - -// Dialog -// ======================================================================== - -// @mixin hook-modal-dialog(){} - - -// Full -// ======================================================================== - -// @mixin hook-modal-full(){} - - -// Sections -// ======================================================================== - - - -// @mixin hook-modal-body(){} - - - - -// Title -// ======================================================================== - -// @mixin hook-modal-title(){} - - -// Close -// ======================================================================== - -// @mixin hook-modal-close(){} - -// @mixin hook-modal-close-hover(){} - -// @mixin hook-modal-close-default(){} - -// @mixin hook-modal-close-default-hover(){} - -// @mixin hook-modal-close-outside(){} - -// @mixin hook-modal-close-outside-hover(){} - - - -// @mixin hook-modal-close-full-hover(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-modal-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/nav.scss b/docs/_sass/uikit/theme/nav.scss deleted file mode 100644 index 191a539458..0000000000 --- a/docs/_sass/uikit/theme/nav.scss +++ /dev/null @@ -1,102 +0,0 @@ -// -// Component: Nav -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$nav-default-font-size: $global-small-font-size !default; - - -// Sublists -// ======================================================================== - -// @mixin hook-nav-sub(){} - - -// Parent icon modifier -// ======================================================================== - -// @mixin hook-nav-parent-icon(){} - - -// Header -// ======================================================================== - -// @mixin hook-nav-header(){} - - -// Divider -// ======================================================================== - -// @mixin hook-nav-divider(){} - - -// Default style modifier -// ======================================================================== - - - -// @mixin hook-nav-default-item(){} - -// @mixin hook-nav-default-item-hover(){} - -// @mixin hook-nav-default-item-active(){} - -// @mixin hook-nav-default-header(){} - -// @mixin hook-nav-default-divider(){} - - -// Primary style modifier -// ======================================================================== - -// @mixin hook-nav-primary(){} - -// @mixin hook-nav-primary-item(){} - -// @mixin hook-nav-primary-item-hover(){} - -// @mixin hook-nav-primary-item-active(){} - -// @mixin hook-nav-primary-header(){} - -// @mixin hook-nav-primary-divider(){} - - -// Style modifier -// ======================================================================== - -// @mixin hook-nav-dividers(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-nav-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-nav-parent-icon(){} - -// @mixin hook-inverse-nav-default-item(){} -// @mixin hook-inverse-nav-default-item-hover(){} -// @mixin hook-inverse-nav-default-item-active(){} -// @mixin hook-inverse-nav-default-header(){} -// @mixin hook-inverse-nav-default-divider(){} - -// @mixin hook-inverse-nav-primary-item(){} -// @mixin hook-inverse-nav-primary-item-hover(){} -// @mixin hook-inverse-nav-primary-item-active(){} -// @mixin hook-inverse-nav-primary-header(){} -// @mixin hook-inverse-nav-primary-divider(){} - -// @mixin hook-inverse-nav-dividers(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/navbar.scss b/docs/_sass/uikit/theme/navbar.scss deleted file mode 100644 index 43aa119f5a..0000000000 --- a/docs/_sass/uikit/theme/navbar.scss +++ /dev/null @@ -1,138 +0,0 @@ -// -// Component: Navbar -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$navbar-nav-item-font-size: $global-small-font-size !default; - -$navbar-dropdown-margin: 15px !default; -$navbar-dropdown-padding: 25px !default; -$navbar-dropdown-background: $global-background !default; -$navbar-dropdown-grid-gutter-horizontal: ($navbar-dropdown-padding * 2) !default; - -// -// New -// - -$navbar-nav-item-text-transform: uppercase !default; - -$navbar-dropdown-nav-font-size: $global-small-font-size !default; - -$navbar-dropdown-box-shadow: 0 5px 12px rgba(0,0,0,0.15) !default; - -$navbar-dropbar-box-shadow: 0 5px 7px rgba(0, 0, 0, 0.05) !default; - -$navbar-dropdown-grid-divider-border-width: $global-border-width !default; -$navbar-dropdown-grid-divider-border: $navbar-dropdown-nav-divider-border !default; - - -// Component -// ======================================================================== - -// @mixin hook-navbar(){} - - -// Container -// ======================================================================== - -// @mixin hook-navbar-container(){} - - -// Nav -// ======================================================================== - - - -// @mixin hook-navbar-nav-item-hover(){} - -// @mixin hook-navbar-nav-item-onclick(){} - -// @mixin hook-navbar-nav-item-active(){} - - -// Item -// ======================================================================== - -// @mixin hook-navbar-item(){} - - -// Toggle -// ======================================================================== - -// @mixin hook-navbar-toggle(){} - -// @mixin hook-navbar-toggle-hover(){} - -// @mixin hook-navbar-toggle-icon(){} - -// @mixin hook-navbar-toggle-icon-hover(){} - - -// Subtitle -// ======================================================================== - -// @mixin hook-navbar-subtitle(){} - - -// Style modifiers -// ======================================================================== - -// @mixin hook-navbar-primary(){} - -// @mixin hook-navbar-transparent(){} - -// @mixin hook-navbar-sticky(){} - - -// Dropdown -// ======================================================================== - - - - - - -// Dropdown nav -// ======================================================================== - - - -// @mixin hook-navbar-dropdown-nav-item(){} - -// @mixin hook-navbar-dropdown-nav-item-hover(){} - -// @mixin hook-navbar-dropdown-nav-header(){} - -// @mixin hook-navbar-dropdown-nav-divider(){} - - -// Dropbar -// ======================================================================== - -// @mixin hook-navbar-dropbar(){} - - - - -// Miscellaneous -// ======================================================================== - - - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-navbar-nav-item(){} -// @mixin hook-inverse-navbar-nav-item-hover(){} -// @mixin hook-inverse-navbar-nav-item-onclick(){} -// @mixin hook-inverse-navbar-nav-item-active(){} - -// @mixin hook-inverse-navbar-item(){} - -// @mixin hook-inverse-navbar-toggle(){} -// @mixin hook-inverse-navbar-toggle-hover(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/notification.scss b/docs/_sass/uikit/theme/notification.scss deleted file mode 100644 index 71c793dbe4..0000000000 --- a/docs/_sass/uikit/theme/notification.scss +++ /dev/null @@ -1,44 +0,0 @@ -// -// Component: Notification -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-notification(){} - - -// Message -// ======================================================================== - -// @mixin hook-notification-message(){} - - -// Close -// ======================================================================== - -// @mixin hook-notification-close(){} - - -// Style modifiers -// ======================================================================== - -// @mixin hook-notification-message-primary(){} - -// @mixin hook-notification-message-success(){} - -// @mixin hook-notification-message-warning(){} - -// @mixin hook-notification-message-danger(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-notification-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/offcanvas.scss b/docs/_sass/uikit/theme/offcanvas.scss deleted file mode 100644 index 283078ef36..0000000000 --- a/docs/_sass/uikit/theme/offcanvas.scss +++ /dev/null @@ -1,32 +0,0 @@ -// -// Component: Off-canvas -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Bar -// ======================================================================== - -// @mixin hook-offcanvas-bar(){} - - -// Close -// ======================================================================== - -// @mixin hook-offcanvas-close(){} - - -// Overlay -// ======================================================================== - -// @mixin hook-offcanvas-overlay(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-offcanvas-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/overlay.scss b/docs/_sass/uikit/theme/overlay.scss deleted file mode 100644 index 68cda45259..0000000000 --- a/docs/_sass/uikit/theme/overlay.scss +++ /dev/null @@ -1,33 +0,0 @@ -// -// Component: Overlay -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-overlay(){} - -// Icon -// ======================================================================== - -// @mixin hook-overlay-icon(){} - - -// Style modifiers -// ======================================================================== - -// @mixin hook-overlay-default(){} - -// @mixin hook-overlay-primary(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-overlay-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/padding.scss b/docs/_sass/uikit/theme/padding.scss deleted file mode 100644 index f0737b873a..0000000000 --- a/docs/_sass/uikit/theme/padding.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Padding -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-padding-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/pagination.scss b/docs/_sass/uikit/theme/pagination.scss deleted file mode 100644 index a777e0c900..0000000000 --- a/docs/_sass/uikit/theme/pagination.scss +++ /dev/null @@ -1,41 +0,0 @@ -// -// Component: Pagination -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-pagination(){} - - -// Items -// ======================================================================== - - - -// @mixin hook-pagination-item-hover(){} - -// @mixin hook-pagination-item-active(){} - -// @mixin hook-pagination-item-disabled(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-pagination-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-pagination-item(){} -// @mixin hook-inverse-pagination-item-hover(){} -// @mixin hook-inverse-pagination-item-active(){} -// @mixin hook-inverse-pagination-item-disabled(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/placeholder.scss b/docs/_sass/uikit/theme/placeholder.scss deleted file mode 100644 index 4ab662cb67..0000000000 --- a/docs/_sass/uikit/theme/placeholder.scss +++ /dev/null @@ -1,29 +0,0 @@ -// -// Component: Placeholder -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$placeholder-background: transparent !default; - -// -// New -// - -$placeholder-border-width: $global-border-width !default; -$placeholder-border: $global-border !default; - - -// Component -// ======================================================================== - - - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-placeholder-misc(){} diff --git a/docs/_sass/uikit/theme/position.scss b/docs/_sass/uikit/theme/position.scss deleted file mode 100644 index fc69520898..0000000000 --- a/docs/_sass/uikit/theme/position.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Position -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-position-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/progress.scss b/docs/_sass/uikit/theme/progress.scss deleted file mode 100644 index 9ca100a3e6..0000000000 --- a/docs/_sass/uikit/theme/progress.scss +++ /dev/null @@ -1,24 +0,0 @@ -// -// Component: Progress -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$progress-border-radius: 500px !default; - - -// Component -// ======================================================================== - - - -// @mixin hook-progress-bar(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-progress-misc(){} diff --git a/docs/_sass/uikit/theme/search.scss b/docs/_sass/uikit/theme/search.scss deleted file mode 100644 index 1cd2103d8a..0000000000 --- a/docs/_sass/uikit/theme/search.scss +++ /dev/null @@ -1,75 +0,0 @@ -// -// Component: Search -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$search-default-background: transparent !default; - -// -// New -// - -$search-default-border-width: $global-border-width !default; -$search-default-border: $global-border !default; - -$search-default-focus-border: $global-primary-background !default; - - -// Component -// ======================================================================== - -// @mixin hook-search-input(){} - - -// Default modifiers -// ======================================================================== - - - - - - -// Navbar modifiers -// ======================================================================== - -// @mixin hook-search-navbar-input(){} - - -// Large modifiers -// ======================================================================== - -// @mixin hook-search-large-input(){} - - -// Toggle -// ======================================================================== - -// @mixin hook-search-toggle(){} - -// @mixin hook-search-toggle-hover(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-search-misc(){} - - -// Inverse -// ======================================================================== - -$inverse-search-default-background: transparent !default; - - -// @mixin hook-inverse-search-default-input-focus(){} - -// @mixin hook-inverse-search-navbar-input(){} - -// @mixin hook-inverse-search-large-input(){} - -// @mixin hook-inverse-search-toggle(){} -// @mixin hook-inverse-search-toggle-hover(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/section.scss b/docs/_sass/uikit/theme/section.scss deleted file mode 100644 index 6d7f761b6e..0000000000 --- a/docs/_sass/uikit/theme/section.scss +++ /dev/null @@ -1,32 +0,0 @@ -// -// Component: Section -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-section(){} - - -// Style modifiers -// ======================================================================== - -// @mixin hook-section-default(){} - -// @mixin hook-section-muted(){} - -// @mixin hook-section-primary(){} - -// @mixin hook-section-secondary(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-section-misc(){} diff --git a/docs/_sass/uikit/theme/slidenav.scss b/docs/_sass/uikit/theme/slidenav.scss deleted file mode 100644 index 60dcb22249..0000000000 --- a/docs/_sass/uikit/theme/slidenav.scss +++ /dev/null @@ -1,52 +0,0 @@ -// -// Component: Slidenav -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - - - -// @mixin hook-slidenav-hover(){} - -// @mixin hook-slidenav-active(){} - - -// Icon modifier -// ======================================================================== - -// @mixin hook-slidenav-previous(){} - -// @mixin hook-slidenav-next(){} - - -// Size modifier -// ======================================================================== - -// @mixin hook-slidenav-large(){} - - -// Container -// ======================================================================== - -// @mixin hook-slidenav-container(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-slidenav-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-slidenav(){} -// @mixin hook-inverse-slidenav-hover(){} -// @mixin hook-inverse-slidenav-active(){} diff --git a/docs/_sass/uikit/theme/slider.scss b/docs/_sass/uikit/theme/slider.scss deleted file mode 100644 index b8d40fa124..0000000000 --- a/docs/_sass/uikit/theme/slider.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Slider -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-slider-misc(){} diff --git a/docs/_sass/uikit/theme/sortable.scss b/docs/_sass/uikit/theme/sortable.scss deleted file mode 100644 index 3ab18c3db1..0000000000 --- a/docs/_sass/uikit/theme/sortable.scss +++ /dev/null @@ -1,38 +0,0 @@ -// -// Component: Sortable -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-sortable(){} - - -// Drag -// ======================================================================== - -// @mixin hook-sortable-drag(){} - - -// Placeholder -// ======================================================================== - -// @mixin hook-sortable-placeholder(){} - - -// Empty -// ======================================================================== - -// @mixin hook-sortable-empty(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-sortable-misc(){} diff --git a/docs/_sass/uikit/theme/spinner.scss b/docs/_sass/uikit/theme/spinner.scss deleted file mode 100644 index d70e10fa81..0000000000 --- a/docs/_sass/uikit/theme/spinner.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Spinner -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-spinner-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/sticky.scss b/docs/_sass/uikit/theme/sticky.scss deleted file mode 100644 index 94e5ee69ee..0000000000 --- a/docs/_sass/uikit/theme/sticky.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Sticky -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-sticky-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/subnav.scss b/docs/_sass/uikit/theme/subnav.scss deleted file mode 100644 index f4d1c7fd06..0000000000 --- a/docs/_sass/uikit/theme/subnav.scss +++ /dev/null @@ -1,74 +0,0 @@ -// -// Component: Subnav -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$subnav-item-font-size: $global-small-font-size !default; -$subnav-item-text-transform: uppercase !default; - - -// Component -// ======================================================================== - -// @mixin hook-subnav(){} - - - -// @mixin hook-subnav-item-hover(){} - -// @mixin hook-subnav-item-active(){} - - -// Divider modifier -// ======================================================================== - -// @mixin hook-subnav-divider(){} - - -// Pill modifier -// ======================================================================== - -// @mixin hook-subnav-pill-item(){} - -// @mixin hook-subnav-pill-item-hover(){} - -// @mixin hook-subnav-pill-item-onclick(){} - -// @mixin hook-subnav-pill-item-active(){} - - -// Disabled -// ======================================================================== - -// @mixin hook-subnav-item-disabled(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-subnav-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-subnav-item(){} -// @mixin hook-inverse-subnav-item-hover(){} -// @mixin hook-inverse-subnav-item-active(){} - -// @mixin hook-inverse-subnav-divider(){} - -// @mixin hook-inverse-subnav-pill-item(){} -// @mixin hook-inverse-subnav-pill-item-hover(){} -// @mixin hook-inverse-subnav-pill-item-onclick(){} -// @mixin hook-inverse-subnav-pill-item-active(){} - -// @mixin hook-inverse-subnav-item-disabled(){} diff --git a/docs/_sass/uikit/theme/tab.scss b/docs/_sass/uikit/theme/tab.scss deleted file mode 100644 index 51c4ba2866..0000000000 --- a/docs/_sass/uikit/theme/tab.scss +++ /dev/null @@ -1,74 +0,0 @@ -// -// Component: Tab -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$tab-border-width: $global-border-width !default; -$tab-border: $global-border !default; - -$tab-item-border-width: $global-border-width !default; -$tab-item-font-size: $global-small-font-size !default; -$tab-item-text-transform: uppercase !default; - -$tab-item-active-border: $global-primary-background !default; - - -// Component -// ======================================================================== - - - - -// Items -// ======================================================================== - - - -// @mixin hook-tab-item-hover(){} - - - -// @mixin hook-tab-item-disabled(){} - - -// Position modifiers -// ======================================================================== - - - - - - - - - - - - - - -// Miscellaneous -// ======================================================================== - - - - -// Inverse -// ======================================================================== - -$inverse-tab-border: $inverse-global-border !default; - - - -// @mixin hook-inverse-tab-item(){} -// @mixin hook-inverse-tab-item-hover(){} - -// @mixin hook-inverse-tab-item-disabled(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/table.scss b/docs/_sass/uikit/theme/table.scss deleted file mode 100644 index d6a660793b..0000000000 --- a/docs/_sass/uikit/theme/table.scss +++ /dev/null @@ -1,68 +0,0 @@ -// -// Component: Table -// -// ======================================================================== - - -// Variables -// ======================================================================== - -$table-header-cell-font-size: $global-small-font-size !default; -$table-header-cell-font-weight: normal !default; -$table-header-cell-color: $global-muted-color !default; - -// -// New -// - -$table-striped-border-width: $global-border-width !default; -$table-striped-border: $global-border !default; - - -// Component -// ======================================================================== - - - -// @mixin hook-table-cell(){} - -// @mixin hook-table-footer(){} - -// @mixin hook-table-caption(){} - -// @mixin hook-table-row-active(){} - - -// Style modifiers -// ======================================================================== - -// @mixin hook-table-divider(){} - - - -// @mixin hook-table-hover(){} - - -// Size modifier -// ======================================================================== - -// @mixin hook-table-small(){} - -// @mixin hook-table-large(){} - - -// Miscellaneous -// ======================================================================== - - - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-table-header-cell(){} -// @mixin hook-inverse-table-caption(){} -// @mixin hook-inverse-table-row-active(){} -// @mixin hook-inverse-table-divider(){} - -// @mixin hook-inverse-table-hover(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/text.scss b/docs/_sass/uikit/theme/text.scss deleted file mode 100644 index b6e35c431f..0000000000 --- a/docs/_sass/uikit/theme/text.scss +++ /dev/null @@ -1,50 +0,0 @@ -// -// Component: Text -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$text-meta-link-color: $text-meta-color !default; -$text-meta-link-hover-color: $global-color !default; - - -// Style modifiers -// ======================================================================== - -// @mixin hook-text-lead(){} - - - - -// Size modifiers -// ======================================================================== - -// @mixin hook-text-small(){} - -// @mixin hook-text-large(){} - - -// Background modifier -// ======================================================================== - -// @mixin hook-text-background(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-text-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-text-lead(){} -// @mixin hook-inverse-text-meta(){} diff --git a/docs/_sass/uikit/theme/thumbnav.scss b/docs/_sass/uikit/theme/thumbnav.scss deleted file mode 100644 index 7f26c38aa8..0000000000 --- a/docs/_sass/uikit/theme/thumbnav.scss +++ /dev/null @@ -1,42 +0,0 @@ -// -// Component: Thumbnav -// -// ======================================================================== - - -// Variables -// ======================================================================== - -// -// New -// - -$thumbnav-item-background: rgba($global-background, 0.4) !default; -$thumbnav-item-hover-background: transparent !default; -$thumbnav-item-active-background: transparent !default; - - -// Component -// ======================================================================== - -// @mixin hook-thumbnav(){} - - - - - - - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-thumbnav-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-thumbnav-item(){} -// @mixin hook-inverse-thumbnav-item-hover(){} -// @mixin hook-inverse-thumbnav-item-active(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/tile.scss b/docs/_sass/uikit/theme/tile.scss deleted file mode 100644 index 2d043a6338..0000000000 --- a/docs/_sass/uikit/theme/tile.scss +++ /dev/null @@ -1,32 +0,0 @@ -// -// Component: Tile -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-tile(){} - - -// Style modifiers -// ======================================================================== - -// @mixin hook-tile-default(){} - -// @mixin hook-tile-muted(){} - -// @mixin hook-tile-primary(){} - -// @mixin hook-tile-secondary(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-tile-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/tooltip.scss b/docs/_sass/uikit/theme/tooltip.scss deleted file mode 100644 index 5115139c31..0000000000 --- a/docs/_sass/uikit/theme/tooltip.scss +++ /dev/null @@ -1,20 +0,0 @@ -// -// Component: Tooltip -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - -// @mixin hook-tooltip(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-tooltip-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/totop.scss b/docs/_sass/uikit/theme/totop.scss deleted file mode 100644 index feb7165a1d..0000000000 --- a/docs/_sass/uikit/theme/totop.scss +++ /dev/null @@ -1,32 +0,0 @@ -// -// Component: Totop -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Component -// ======================================================================== - - - -// @mixin hook-totop-hover(){} - -// @mixin hook-totop-active(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-icon-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-totop(){} -// @mixin hook-inverse-totop-hover(){} -// @mixin hook-inverse-totop-active(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/transition.scss b/docs/_sass/uikit/theme/transition.scss deleted file mode 100644 index fd7bdede45..0000000000 --- a/docs/_sass/uikit/theme/transition.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Transition -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-transition-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/utility.scss b/docs/_sass/uikit/theme/utility.scss deleted file mode 100644 index 69094998a0..0000000000 --- a/docs/_sass/uikit/theme/utility.scss +++ /dev/null @@ -1,49 +0,0 @@ -// -// Component: Utility -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Panel -// ======================================================================== - -// @mixin hook-panel-scrollable(){} - - -// Box-shadow bottom -// ======================================================================== - -// @mixin hook-box-shadow-bottom(){} - - -// Drop cap -// ======================================================================== - - - - -// Logo -// ======================================================================== - -// @mixin hook-logo(){} - -// @mixin hook-logo-hover(){} - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-utility-misc(){} - - -// Inverse -// ======================================================================== - -// @mixin hook-inverse-dropcap(){} - -// @mixin hook-inverse-logo(){} -// @mixin hook-inverse-logo-hover(){} \ No newline at end of file diff --git a/docs/_sass/uikit/theme/variables.scss b/docs/_sass/uikit/theme/variables.scss deleted file mode 100644 index d74b3a9971..0000000000 --- a/docs/_sass/uikit/theme/variables.scss +++ /dev/null @@ -1,36 +0,0 @@ -// -// Component: Variables -// -// ======================================================================== - - -// Global variables -// ======================================================================== - -// -// Typography -// - -// -// Colors -// - -// -// Backgrounds -// - -// -// Borders -// - -// -// Spacings -// - -// -// Controls -// - -// -// Z-index -// \ No newline at end of file diff --git a/docs/_sass/uikit/theme/width.scss b/docs/_sass/uikit/theme/width.scss deleted file mode 100644 index b67a7954ed..0000000000 --- a/docs/_sass/uikit/theme/width.scss +++ /dev/null @@ -1,14 +0,0 @@ -// -// Component: Width -// -// ======================================================================== - - -// Variables -// ======================================================================== - - -// Miscellaneous -// ======================================================================== - -// @mixin hook-width-misc(){} \ No newline at end of file diff --git a/docs/_sass/uikit/uikit-theme.scss b/docs/_sass/uikit/uikit-theme.scss deleted file mode 100644 index d114210fba..0000000000 --- a/docs/_sass/uikit/uikit-theme.scss +++ /dev/null @@ -1,9 +0,0 @@ -// -// Theme -// - -@import "https://github.com/uikit/theme/import"; - -@import "https://github.com/uikit/components/import"; - - diff --git a/docs/_sass/uikit/uikit.scss b/docs/_sass/uikit/uikit.scss deleted file mode 100644 index d4a737e989..0000000000 --- a/docs/_sass/uikit/uikit.scss +++ /dev/null @@ -1,5 +0,0 @@ -// -// Core -// - -@import "https://github.com/uikit/components/import"; \ No newline at end of file diff --git a/docs/_sass/uikit/variables-theme.scss b/docs/_sass/uikit/variables-theme.scss deleted file mode 100644 index 2f0f90ef51..0000000000 --- a/docs/_sass/uikit/variables-theme.scss +++ /dev/null @@ -1,1172 +0,0 @@ -$global-margin: 20px !default; -$accordion-item-margin-top: $global-margin !default; -$global-medium-font-size: 1.25rem !default; -$accordion-title-font-size: $global-medium-font-size !default; -$accordion-title-line-height: 1.4 !default; -$global-emphasis-color: #333 !default; -$accordion-title-color: $global-emphasis-color !default; -$global-color: #666 !default; -$accordion-title-hover-color: $global-color !default; -$accordion-content-margin-top: $global-margin !default; -$global-inverse-color: #fff !default; -$inverse-global-emphasis-color: $global-inverse-color !default; -$inverse-accordion-title-color: $inverse-global-emphasis-color !default; -$inverse-global-color: rgba($global-inverse-color, 0.7) !default; -$inverse-accordion-title-hover-color: $inverse-global-color !default; -$alert-margin-vertical: $global-margin !default; -$alert-padding: 15px !default; -$alert-padding-right: $alert-padding + 14px !default; -$global-muted-background: #f8f8f8 !default; -$alert-background: $global-muted-background !default; -$alert-color: $global-color !default; -$alert-close-top: $alert-padding + 5px !default; -$alert-close-right: $alert-padding !default; -$global-primary-background: #1e87f0 !default; -$alert-primary-background: lighten(mix(white, $global-primary-background, 40%), 20%) !default; -$alert-primary-color: $global-primary-background !default; -$global-success-background: #32d296 !default; -$alert-success-background: lighten(mix(white, $global-success-background, 40%), 25%) !default; -$alert-success-color: $global-success-background !default; -$global-warning-background: #faa05a !default; -$alert-warning-background: lighten(mix(white, $global-warning-background, 45%), 15%) !default; -$alert-warning-color: $global-warning-background !default; -$global-danger-background: #f0506e !default; -$alert-danger-background: lighten(mix(white, $global-danger-background, 40%), 20%) !default; -$alert-danger-color: $global-danger-background !default; -$global-gutter: 30px !default; -$align-margin-horizontal: $global-gutter !default; -$align-margin-vertical: $global-gutter !default; -$global-medium-gutter: 40px !default; -$align-margin-horizontal-l: $global-medium-gutter !default; -$animation-duration: 0.5s !default; -$animation-fade-duration: 0.8s !default; -$animation-stroke-duration: 2s !default; -$animation-kenburns-duration: 15s !default; -$animation-fast-duration: 0.1s !default; -$animation-slide-small-translate: 10px !default; -$animation-slide-medium-translate: 50px !default; -$global-large-margin: 70px !default; -$article-margin-top: $global-large-margin !default; -$global-2xlarge-font-size: 2.625rem !default; -$article-title-font-size-m: $global-2xlarge-font-size !default; -$article-title-font-size: $article-title-font-size-m * 0.85 !default; -$article-title-line-height: 1.2 !default; -$global-small-font-size: 0.875rem !default; -$article-meta-font-size: $global-small-font-size !default; -$article-meta-line-height: 1.4 !default; -$global-muted-color: #999 !default; -$article-meta-color: $global-muted-color !default; -$inverse-global-muted-color: rgba($global-inverse-color, 0.5) !default; -$inverse-article-meta-color: $inverse-global-muted-color !default; -$global-background: #fff !default; -$background-default-background: $global-background !default; -$background-muted-background: $global-muted-background !default; -$background-primary-background: $global-primary-background !default; -$global-secondary-background: #222 !default; -$background-secondary-background: $global-secondary-background !default; -$badge-size: 18px !default; -$badge-padding-vertical: 0 !default; -$badge-padding-horizontal: 5px !default; -$badge-border-radius: 500px !default; -$badge-background: $global-primary-background !default; -$badge-color: $global-inverse-color !default; -$badge-font-size: 11px !default; -$inverse-global-primary-background: $global-inverse-color !default; -$inverse-badge-background: $inverse-global-primary-background !default; -$inverse-global-inverse-color: $global-color !default; -$inverse-badge-color: $inverse-global-inverse-color !default; -$base-body-background: $global-background !default; -$global-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default; -$base-body-font-family: $global-font-family !default; -$base-body-font-weight: normal !default; -$global-font-size: 16px !default; -$base-body-font-size: $global-font-size !default; -$global-line-height: 1.5 !default; -$base-body-line-height: $global-line-height !default; -$base-body-color: $global-color !default; -$global-link-color: #1e87f0 !default; -$base-link-color: $global-link-color !default; -$base-link-text-decoration: none !default; -$global-link-hover-color: #0f6ecd !default; -$base-link-hover-color: $global-link-hover-color !default; -$base-link-hover-text-decoration: underline !default; -$base-strong-font-weight: bolder !default; -$base-code-font-size: $global-small-font-size !default; -$base-code-font-family: Consolas, monaco, monospace !default; -$base-code-color: $global-danger-background !default; -$base-em-color: $global-danger-background !default; -$base-ins-background: #ffd !default; -$base-ins-color: $global-color !default; -$base-mark-background: #ffd !default; -$base-mark-color: $global-color !default; -$base-quote-font-style: italic !default; -$base-small-font-size: 80% !default; -$base-margin-vertical: $global-margin !default; -$base-heading-font-family: $global-font-family !default; -$base-heading-font-weight: normal !default; -$base-heading-color: $global-emphasis-color !default; -$base-heading-text-transform: none !default; -$global-medium-margin: 40px !default; -$base-heading-margin-top: $global-medium-margin !default; -$base-h1-font-size-m: $global-2xlarge-font-size !default; -$base-h1-font-size: $base-h1-font-size-m * 0.85 !default; -$base-h1-line-height: 1.2 !default; -$global-xlarge-font-size: 2rem !default; -$base-h2-font-size-m: $global-xlarge-font-size !default; -$base-h2-font-size: $base-h2-font-size-m * 0.85 !default; -$base-h2-line-height: 1.3 !default; -$global-large-font-size: 1.5rem !default; -$base-h3-font-size: $global-large-font-size !default; -$base-h3-line-height: 1.4 !default; -$base-h4-font-size: $global-medium-font-size !default; -$base-h4-line-height: 1.4 !default; -$base-h5-font-size: $global-font-size !default; -$base-h5-line-height: 1.4 !default; -$base-h6-font-size: $global-small-font-size !default; -$base-h6-line-height: 1.4 !default; -$base-list-padding-left: 30px !default; -$base-hr-margin-vertical: $global-margin !default; -$global-border-width: 1px !default; -$base-hr-border-width: $global-border-width !default; -$global-border: #e5e5e5 !default; -$base-hr-border: $global-border !default; -$base-blockquote-font-size: $global-medium-font-size !default; -$base-blockquote-line-height: 1.5 !default; -$base-blockquote-font-style: italic !default; -$base-blockquote-margin-vertical: $global-margin !default; -$global-small-margin: 10px !default; -$base-blockquote-footer-margin-top: $global-small-margin !default; -$base-blockquote-footer-font-size: $global-small-font-size !default; -$base-blockquote-footer-line-height: 1.5 !default; -$base-pre-font-size: $global-small-font-size !default; -$base-pre-line-height: 1.5 !default; -$base-pre-font-family: $base-code-font-family !default; -$base-pre-color: $global-color !default; -$base-selection-background: #39f !default; -$base-selection-color: $global-inverse-color !default; -$inverse-base-color: $inverse-global-color !default; -$inverse-base-link-color: $inverse-global-emphasis-color !default; -$inverse-base-link-hover-color: $inverse-global-emphasis-color !default; -$inverse-base-code-color: $inverse-global-color !default; -$inverse-base-em-color: $inverse-global-emphasis-color !default; -$inverse-base-heading-color: $inverse-global-emphasis-color !default; -$inverse-global-border: rgba($global-inverse-color, 0.2) !default; -$inverse-base-hr-border: $inverse-global-border !default; -$breadcrumb-item-font-size: $global-small-font-size !default; -$breadcrumb-item-color: $global-muted-color !default; -$breadcrumb-item-hover-color: $global-color !default; -$breadcrumb-item-hover-text-decoration: none !default; -$breadcrumb-item-active-color: $global-color !default; -$breadcrumb-divider: "/" !default; -$breadcrumb-divider-margin-horizontal: 20px !default; -$breadcrumb-divider-font-size: $breadcrumb-item-font-size !default; -$breadcrumb-divider-color: $global-muted-color !default; -$inverse-breadcrumb-item-color: $inverse-global-muted-color !default; -$inverse-breadcrumb-item-hover-color: $inverse-global-color !default; -$inverse-breadcrumb-item-active-color: $inverse-global-color !default; -$inverse-breadcrumb-divider-color: $inverse-global-muted-color !default; -$global-control-height: 40px !default; -$button-border-width: $global-border-width !default; -$button-line-height: $global-control-height - ($button-border-width * 2) !default; -$global-control-small-height: 30px !default; -$button-small-line-height: $global-control-small-height - ($button-border-width * 2) !default; -$global-control-large-height: 55px !default; -$button-large-line-height: $global-control-large-height - ($button-border-width * 2) !default; -$button-font-size: $global-small-font-size !default; -$button-small-font-size: $global-small-font-size !default; -$button-large-font-size: $global-small-font-size !default; -$button-padding-horizontal: $global-gutter !default; -$global-small-gutter: 15px !default; -$button-small-padding-horizontal: $global-small-gutter !default; -$button-large-padding-horizontal: $global-medium-gutter !default; -$button-default-background: transparent !default; -$button-default-color: $global-emphasis-color !default; -$button-default-hover-background: transparent !default; -$button-default-hover-color: $global-emphasis-color !default; -$button-default-active-background: transparent !default; -$button-default-active-color: $global-emphasis-color !default; -$button-primary-background: $global-primary-background !default; -$button-primary-color: $global-inverse-color !default; -$button-primary-hover-background: darken($button-primary-background, 5%) !default; -$button-primary-hover-color: $global-inverse-color !default; -$button-primary-active-background: darken($button-primary-background, 10%) !default; -$button-primary-active-color: $global-inverse-color !default; -$button-secondary-background: $global-secondary-background !default; -$button-secondary-color: $global-inverse-color !default; -$button-secondary-hover-background: darken($button-secondary-background, 5%) !default; -$button-secondary-hover-color: $global-inverse-color !default; -$button-secondary-active-background: darken($button-secondary-background, 10%) !default; -$button-secondary-active-color: $global-inverse-color !default; -$button-danger-background: $global-danger-background !default; -$button-danger-color: $global-inverse-color !default; -$button-danger-hover-background: darken($button-danger-background, 5%) !default; -$button-danger-hover-color: $global-inverse-color !default; -$button-danger-active-background: darken($button-danger-background, 10%) !default; -$button-danger-active-color: $global-inverse-color !default; -$button-disabled-background: transparent !default; -$button-disabled-color: $global-muted-color !default; -$button-text-line-height: $global-line-height !default; -$button-text-color: $global-emphasis-color !default; -$button-text-hover-color: $global-emphasis-color !default; -$button-text-disabled-color: $global-muted-color !default; -$button-link-line-height: $global-line-height !default; -$button-link-color: $global-emphasis-color !default; -$button-link-hover-color: $global-muted-color !default; -$button-link-hover-text-decoration: none !default; -$button-link-disabled-color: $global-muted-color !default; -$inverse-button-default-background: transparent !default; -$inverse-button-default-color: $inverse-global-emphasis-color !default; -$inverse-button-default-hover-background: transparent !default; -$inverse-button-default-hover-color: $inverse-global-emphasis-color !default; -$inverse-button-default-active-background: transparent !default; -$inverse-button-default-active-color: $inverse-global-emphasis-color !default; -$inverse-button-primary-background: $inverse-global-primary-background !default; -$inverse-button-primary-color: $inverse-global-inverse-color !default; -$inverse-button-primary-hover-background: darken($inverse-button-primary-background, 5%) !default; -$inverse-button-primary-hover-color: $inverse-global-inverse-color !default; -$inverse-button-primary-active-background: darken($inverse-button-primary-background, 10%) !default; -$inverse-button-primary-active-color: $inverse-global-inverse-color !default; -$inverse-button-secondary-background: $inverse-global-primary-background !default; -$inverse-button-secondary-color: $inverse-global-inverse-color !default; -$inverse-button-secondary-hover-background: darken($inverse-button-secondary-background, 5%) !default; -$inverse-button-secondary-hover-color: $inverse-global-inverse-color !default; -$inverse-button-secondary-active-background: darken($inverse-button-secondary-background, 10%) !default; -$inverse-button-secondary-active-color: $inverse-global-inverse-color !default; -$inverse-button-text-color: $inverse-global-emphasis-color !default; -$inverse-button-text-hover-color: $inverse-global-emphasis-color !default; -$inverse-button-text-disabled-color: $inverse-global-muted-color !default; -$inverse-button-link-color: $inverse-global-emphasis-color !default; -$inverse-button-link-hover-color: $inverse-global-muted-color !default; -$card-body-padding-horizontal: $global-gutter !default; -$card-body-padding-vertical: $global-gutter !default; -$card-body-padding-horizontal-l: $global-medium-gutter !default; -$card-body-padding-vertical-l: $global-medium-gutter !default; -$card-header-padding-horizontal: $global-gutter !default; -$card-header-padding-vertical: round($global-gutter / 2) !default; -$card-header-padding-horizontal-l: $global-medium-gutter !default; -$card-header-padding-vertical-l: round($global-medium-gutter / 2) !default; -$card-footer-padding-horizontal: $global-gutter !default; -$card-footer-padding-vertical: ($global-gutter / 2) !default; -$card-footer-padding-horizontal-l: $global-medium-gutter !default; -$card-footer-padding-vertical-l: round($global-medium-gutter / 2) !default; -$card-title-font-size: $global-large-font-size !default; -$card-title-line-height: 1.4 !default; -$card-badge-top: 15px !default; -$card-badge-right: 15px !default; -$card-badge-height: 22px !default; -$card-badge-padding-horizontal: 10px !default; -$card-badge-background: $global-primary-background !default; -$card-badge-color: $global-inverse-color !default; -$card-badge-font-size: $global-small-font-size !default; -$card-hover-background: $global-background !default; -$card-default-background: $global-background !default; -$card-default-color: $global-color !default; -$card-default-title-color: $global-emphasis-color !default; -$card-default-hover-background: $card-default-background !default; -$card-primary-background: $global-primary-background !default; -$card-primary-color: $global-inverse-color !default; -$card-primary-title-color: $card-primary-color !default; -$card-primary-hover-background: $card-primary-background !default; -$card-primary-color-mode: light !default; -$card-secondary-background: $global-secondary-background !default; -$card-secondary-color: $global-inverse-color !default; -$card-secondary-title-color: $card-secondary-color !default; -$card-secondary-hover-background: $card-secondary-background !default; -$card-secondary-color-mode: light !default; -$card-small-body-padding-horizontal: $global-margin !default; -$card-small-body-padding-vertical: $global-margin !default; -$card-small-header-padding-horizontal: $global-margin !default; -$card-small-header-padding-vertical: round($global-margin / 1.5) !default; -$card-small-footer-padding-horizontal: $global-margin !default; -$card-small-footer-padding-vertical: round($global-margin / 1.5) !default; -$global-large-gutter: 70px !default; -$card-large-body-padding-horizontal-l: $global-large-gutter !default; -$card-large-body-padding-vertical-l: $global-large-gutter !default; -$card-large-header-padding-horizontal-l: $global-large-gutter !default; -$card-large-header-padding-vertical-l: round($global-large-gutter / 2) !default; -$card-large-footer-padding-horizontal-l: $global-large-gutter !default; -$card-large-footer-padding-vertical-l: round($global-large-gutter / 2) !default; -$inverse-card-badge-background: $inverse-global-primary-background !default; -$inverse-card-badge-color: $inverse-global-inverse-color !default; -$close-color: $global-muted-color !default; -$close-hover-color: $global-color !default; -$inverse-close-color: $inverse-global-muted-color !default; -$inverse-close-hover-color: $inverse-global-color !default; -$column-gutter: $global-gutter !default; -$column-gutter-l: $global-medium-gutter !default; -$column-divider-rule-color: $global-border !default; -$column-divider-rule-width: 1px !default; -$inverse-column-divider-rule-color: $inverse-global-border !default; -$comment-header-margin-bottom: $global-margin !default; -$comment-title-font-size: $global-medium-font-size !default; -$comment-title-line-height: 1.4 !default; -$comment-meta-font-size: $global-small-font-size !default; -$comment-meta-line-height: 1.4 !default; -$comment-meta-color: $global-muted-color !default; -$comment-list-margin-top: $global-large-margin !default; -$comment-list-padding-left: 30px !default; -$comment-list-padding-left-m: 100px !default; -$container-max-width: 1200px !default; -$container-xsmall-max-width: 750px !default; -$container-small-max-width: 900px !default; -$container-large-max-width: 1400px !default; -$container-xlarge-max-width: 1600px !default; -$container-padding-horizontal: 15px !default; -$container-padding-horizontal-s: $global-gutter !default; -$container-padding-horizontal-m: $global-medium-gutter !default; -$countdown-number-line-height: 0.8 !default; -$countdown-number-font-size: 2rem !default; -$countdown-number-font-size-s: 4rem !default; -$countdown-number-font-size-m: 6rem !default; -$countdown-separator-line-height: 1.6 !default; -$countdown-separator-font-size: 1rem !default; -$countdown-separator-font-size-s: 2rem !default; -$countdown-separator-font-size-m: 3rem !default; -$description-list-term-color: $global-emphasis-color !default; -$description-list-term-margin-top: $global-margin !default; -$description-list-divider-term-margin-top: $global-margin !default; -$description-list-divider-term-border-width: $global-border-width !default; -$description-list-divider-term-border: $global-border !default; -$divider-margin-vertical: $global-margin !default; -$divider-icon-width: 50px !default; -$divider-icon-height: 20px !default; -$divider-icon-color: $global-border !default; -$divider-icon-line-top: 50% !default; -$divider-icon-line-width: 100% !default; -$divider-icon-line-border-width: $global-border-width !default; -$divider-icon-line-border: $global-border !default; -$internal-divider-icon-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Ccircle%20fill%3D%22none%22%20stroke%3D%22#000%22%20stroke-width%3D%222%22%20cx%3D%2210%22%20cy%3D%2210%22%20r%3D%227%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; -$divider-small-width: 100px !default; -$divider-small-border-width: $global-border-width !default; -$divider-small-border: $global-border !default; -$divider-vertical-height: 100px !default; -$divider-vertical-border-width: $global-border-width !default; -$divider-vertical-border: $global-border !default; -$inverse-divider-icon-color: $inverse-global-border !default; -$inverse-divider-icon-line-border: $inverse-global-border !default; -$inverse-divider-small-border: $inverse-global-border !default; -$inverse-divider-vertical-border: $inverse-global-border !default; -$dotnav-margin-horizontal: 12px !default; -$dotnav-margin-vertical: $dotnav-margin-horizontal !default; -$dotnav-item-width: 10px !default; -$dotnav-item-height: $dotnav-item-width !default; -$dotnav-item-border-radius: 50% !default; -$dotnav-item-background: transparent !default; -$dotnav-item-hover-background: rgba($global-color, 0.6) !default; -$dotnav-item-onclick-background: rgba($global-color, 0.2) !default; -$dotnav-item-active-background: rgba($global-color, 0.6) !default; -$inverse-dotnav-item-background: transparent !default; -$inverse-dotnav-item-hover-background: rgba($inverse-global-color, 0.9) !default; -$inverse-dotnav-item-onclick-background: rgba($inverse-global-color, 0.5) !default; -$inverse-dotnav-item-active-background: rgba($inverse-global-color, 0.9) !default; -$global-z-index: 1000 !default; -$drop-z-index: $global-z-index + 20 !default; -$drop-width: 300px !default; -$drop-margin: $global-margin !default; -$dropdown-z-index: $global-z-index + 20 !default; -$dropdown-min-width: 200px !default; -$dropdown-padding: 25px !default; -$dropdown-background: $global-background !default; -$dropdown-color: $global-color !default; -$dropdown-margin: $global-small-margin !default; -$dropdown-nav-item-color: $global-muted-color !default; -$dropdown-nav-item-hover-color: $global-color !default; -$dropdown-nav-header-color: $global-emphasis-color !default; -$dropdown-nav-divider-border-width: $global-border-width !default; -$dropdown-nav-divider-border: $global-border !default; -$dropdown-nav-sublist-item-color: $global-muted-color !default; -$dropdown-nav-sublist-item-hover-color: $global-color !default; -$form-range-thumb-height: 15px !default; -$form-range-thumb-width: $form-range-thumb-height !default; -$form-range-thumb-border-radius: 500px !default; -$form-range-thumb-background: $global-background !default; -$form-range-track-height: 3px !default; -$form-range-track-background: darken($global-muted-background, 5%) !default; -$form-range-track-focus-background: darken($form-range-track-background, 5%) !default; -$form-height: $global-control-height !default; -$form-border-width: $global-border-width !default; -$form-line-height: $form-height - (2* $form-border-width) !default; -$form-padding-horizontal: 10px !default; -$form-padding-vertical: round($form-padding-horizontal * 0.6) !default; -$form-background: $global-background !default; -$form-color: $global-color !default; -$form-focus-background: $global-background !default; -$form-focus-color: $global-color !default; -$form-disabled-background: $global-muted-background !default; -$form-disabled-color: $global-muted-color !default; -$form-placeholder-color: $global-muted-color !default; -$form-small-height: $global-control-small-height !default; -$form-small-padding-horizontal: 8px !default; -$form-small-padding-vertical: round($form-small-padding-horizontal * 0.6) !default; -$form-small-line-height: $form-small-height - (2* $form-border-width) !default; -$form-small-font-size: $global-small-font-size !default; -$form-large-height: $global-control-large-height !default; -$form-large-padding-horizontal: 12px !default; -$form-large-padding-vertical: round($form-large-padding-horizontal * 0.6) !default; -$form-large-line-height: $form-large-height - (2* $form-border-width) !default; -$form-large-font-size: $global-medium-font-size !default; -$form-danger-color: $global-danger-background !default; -$form-success-color: $global-success-background !default; -$form-width-xsmall: 50px !default; -$form-width-small: 130px !default; -$form-width-medium: 200px !default; -$form-width-large: 500px !default; -$form-select-padding-right: 20px !default; -$form-select-icon-color: $global-color !default; -$form-select-option-color: #444 !default; -$form-select-disabled-icon-color: $global-muted-color !default; -$form-datalist-padding-right: 20px !default; -$form-datalist-icon-color: $global-color !default; -$form-radio-size: 16px !default; -$form-radio-margin-top: -4px !default; -$form-radio-background: transparent !default; -$form-radio-focus-background: darken($form-radio-background, 5%) !default; -$form-radio-checked-background: $global-primary-background !default; -$form-radio-checked-icon-color: $global-inverse-color !default; -$form-radio-checked-focus-background: darken($global-primary-background, 10%) !default; -$form-radio-disabled-background: $global-muted-background !default; -$form-radio-disabled-icon-color: $global-muted-color !default; -$form-legend-font-size: $global-large-font-size !default; -$form-legend-line-height: 1.4 !default; -$form-stacked-margin-bottom: 5px !default; -$form-horizontal-label-width: 200px !default; -$form-horizontal-label-margin-top: 7px !default; -$form-horizontal-controls-margin-left: 215px !default; -$form-horizontal-controls-text-padding-top: 7px !default; -$form-icon-width: $form-height !default; -$form-icon-color: $global-muted-color !default; -$form-icon-hover-color: $global-color !default; -$internal-form-select-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2224%22%20height%3D%2216%22%20viewBox%3D%220%200%2024%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%201%209%206%2015%206%22%20%2F%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%2013%209%208%2015%208%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; -$internal-form-datalist-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2224%22%20height%3D%2216%22%20viewBox%3D%220%200%2024%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%2012%208%206%2016%206%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; -$internal-form-radio-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Ccircle%20fill%3D%22#000%22%20cx%3D%228%22%20cy%3D%228%22%20r%3D%222%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$internal-form-checkbox-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2211%22%20viewBox%3D%220%200%2014%2011%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%201%205%207.5%202%205%201%205.5%205%2010%2013%201.5%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; -$internal-form-checkbox-indeterminate-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Crect%20fill%3D%22#000%22%20x%3D%223%22%20y%3D%228%22%20width%3D%2210%22%20height%3D%221%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$inverse-global-muted-background: rgba($global-inverse-color, 0.1) !default; -$inverse-form-background: $inverse-global-muted-background !default; -$inverse-form-color: $inverse-global-color !default; -$inverse-form-focus-background: fadein($inverse-form-background, 5%) !default; -$inverse-form-focus-color: $inverse-global-color !default; -$inverse-form-placeholder-color: $inverse-global-muted-color !default; -$inverse-form-select-icon-color: $inverse-global-color !default; -$inverse-form-datalist-icon-color: $inverse-global-color !default; -$inverse-form-radio-background: $inverse-global-muted-background !default; -$inverse-form-radio-focus-background: fadein($inverse-form-radio-background, 5%) !default; -$inverse-form-radio-checked-background: $inverse-global-primary-background !default; -$inverse-form-radio-checked-icon-color: $inverse-global-inverse-color !default; -$inverse-form-radio-checked-focus-background: fadein($inverse-global-primary-background, 10%) !default; -$inverse-form-icon-color: $inverse-global-muted-color !default; -$inverse-form-icon-hover-color: $inverse-global-color !default; -$grid-gutter-horizontal: $global-gutter !default; -$grid-gutter-vertical: $grid-gutter-horizontal !default; -$grid-gutter-horizontal-l: $global-medium-gutter !default; -$grid-gutter-vertical-l: $grid-gutter-horizontal-l !default; -$grid-small-gutter-horizontal: $global-small-gutter !default; -$grid-small-gutter-vertical: $grid-small-gutter-horizontal !default; -$grid-medium-gutter-horizontal: $global-gutter !default; -$grid-medium-gutter-vertical: $grid-medium-gutter-horizontal !default; -$grid-large-gutter-horizontal: $global-medium-gutter !default; -$grid-large-gutter-vertical: $grid-large-gutter-horizontal !default; -$grid-large-gutter-horizontal-l: $global-large-gutter !default; -$grid-large-gutter-vertical-l: $grid-large-gutter-horizontal-l !default; -$grid-divider-border-width: $global-border-width !default; -$grid-divider-border: $global-border !default; -$inverse-grid-divider-border: $inverse-global-border !default; -$heading-medium-font-size-l: 4rem !default; -$heading-small-font-size-m: $heading-medium-font-size-l * 0.8125 !default; -$heading-small-font-size: $heading-small-font-size-m * 0.8 !default; -$heading-medium-font-size-m: $heading-medium-font-size-l * 0.875 !default; -$heading-medium-font-size: $heading-medium-font-size-m * 0.825 !default; -$heading-large-font-size-m: $heading-medium-font-size-l !default; -$heading-large-font-size: $heading-large-font-size-m * 0.85 !default; -$heading-xlarge-font-size: $heading-large-font-size-m !default; -$heading-large-font-size-l: 6rem !default; -$heading-xlarge-font-size-m: $heading-large-font-size-l !default; -$heading-2xlarge-font-size: $heading-xlarge-font-size-m !default; -$heading-xlarge-font-size-l: 8rem !default; -$heading-2xlarge-font-size-m: $heading-xlarge-font-size-l !default; -$heading-2xlarge-font-size-l: 11rem !default; -$heading-small-line-height: 1.2 !default; -$heading-medium-line-height: 1.1 !default; -$heading-large-line-height: 1.1 !default; -$heading-xlarge-line-height: 1 !default; -$heading-2xlarge-line-height: 1 !default; -$heading-divider-padding-bottom: unquote('calc(5px + 0.1em)') !default; -$heading-divider-border-width: unquote('calc(0.2px + 0.05em)') !default; -$heading-divider-border: $global-border !default; -$heading-bullet-top: unquote('calc(-0.1 * 1em)') !default; -$heading-bullet-height: unquote('calc(4px + 0.7em)') !default; -$heading-bullet-margin-right: unquote('calc(5px + 0.2em)') !default; -$heading-bullet-border-width: unquote('calc(5px + 0.1em)') !default; -$heading-bullet-border: $global-border !default; -$heading-line-top: 50% !default; -$heading-line-border-width: unquote('calc(0.2px + 0.05em)') !default; -$heading-line-height: $heading-line-border-width !default; -$heading-line-width: 2000px !default; -$heading-line-border: $global-border !default; -$heading-line-margin-horizontal: unquote('calc(5px + 0.3em)') !default; -$heading-primary-font-size-l: 3.75rem !default; -$heading-primary-line-height-l: 1.1 !default; -$heading-primary-font-size-m: $heading-primary-font-size-l * 0.9 !default; -$heading-primary-font-size: $heading-primary-font-size-l * 0.8 !default; -$heading-primary-line-height: 1.2 !default; -$heading-hero-font-size-l: 8rem !default; -$heading-hero-line-height-l: 1 !default; -$heading-hero-font-size-m: $heading-hero-font-size-l * 0.75 !default; -$heading-hero-line-height-m: 1 !default; -$heading-hero-font-size: $heading-hero-font-size-l * 0.5 !default; -$heading-hero-line-height: 1.1 !default; -$inverse-heading-divider-border: $inverse-global-border !default; -$inverse-heading-bullet-border: $inverse-global-border !default; -$inverse-heading-line-border: $inverse-global-border !default; -$height-small-height: 150px !default; -$height-medium-height: 300px !default; -$height-large-height: 450px !default; -$icon-image-size: 20px !default; -$icon-link-color: $global-muted-color !default; -$icon-link-hover-color: $global-color !default; -$icon-link-active-color: darken($global-color, 5%) !default; -$icon-button-size: 36px !default; -$icon-button-border-radius: 500px !default; -$icon-button-background: $global-muted-background !default; -$icon-button-color: $global-muted-color !default; -$icon-button-hover-background: darken($icon-button-background, 5%) !default; -$icon-button-hover-color: $global-color !default; -$icon-button-active-background: darken($icon-button-background, 10%) !default; -$icon-button-active-color: $global-color !default; -$inverse-icon-link-color: $inverse-global-muted-color !default; -$inverse-icon-link-hover-color: $inverse-global-color !default; -$inverse-icon-link-active-color: $inverse-global-color !default; -$inverse-icon-button-background: $inverse-global-muted-background !default; -$inverse-icon-button-color: $inverse-global-muted-color !default; -$inverse-icon-button-hover-background: fadein($inverse-icon-button-background, 5%) !default; -$inverse-icon-button-hover-color: $inverse-global-color !default; -$inverse-icon-button-active-background: fadein($inverse-icon-button-background, 10%) !default; -$inverse-icon-button-active-color: $inverse-global-color !default; -$iconnav-margin-horizontal: $global-small-margin !default; -$iconnav-margin-vertical: $iconnav-margin-horizontal !default; -$iconnav-item-color: $global-muted-color !default; -$iconnav-item-hover-color: $global-color !default; -$iconnav-item-active-color: $global-color !default; -$inverse-iconnav-item-color: $inverse-global-muted-color !default; -$inverse-iconnav-item-hover-color: $inverse-global-color !default; -$inverse-iconnav-item-active-color: $inverse-global-color !default; -$inverse-global-color-mode: light !default; -$label-padding-vertical: 0 !default; -$label-padding-horizontal: $global-small-margin !default; -$label-background: $global-primary-background !default; -$label-line-height: $global-line-height !default; -$label-font-size: $global-small-font-size !default; -$label-color: $global-inverse-color !default; -$label-success-background: $global-success-background !default; -$label-success-color: $global-inverse-color !default; -$label-warning-background: $global-warning-background !default; -$label-warning-color: $global-inverse-color !default; -$label-danger-background: $global-danger-background !default; -$label-danger-color: $global-inverse-color !default; -$inverse-label-background: $inverse-global-primary-background !default; -$inverse-label-color: $inverse-global-inverse-color !default; -$leader-fill-content: unquote('.') !default; -$leader-fill-margin-left: $global-small-gutter !default; -$lightbox-z-index: $global-z-index + 10 !default; -$lightbox-background: #000 !default; -$lightbox-item-color: rgba(255,255,255,0.7) !default; -$lightbox-item-max-width: 100vw !default; -$lightbox-item-max-height: 100vh !default; -$lightbox-toolbar-padding-vertical: 10px !default; -$lightbox-toolbar-padding-horizontal: 10px !default; -$lightbox-toolbar-background: rgba(0,0,0,0.3) !default; -$lightbox-toolbar-color: rgba(255,255,255,0.7) !default; -$lightbox-toolbar-icon-padding: 5px !default; -$lightbox-toolbar-icon-color: rgba(255,255,255,0.7) !default; -$lightbox-toolbar-icon-hover-color: #fff !default; -$lightbox-button-size: 50px !default; -$lightbox-button-background: $lightbox-toolbar-background !default; -$lightbox-button-color: rgba(255,255,255,0.7) !default; -$lightbox-button-hover-color: #fff !default; -$link-muted-color: $global-muted-color !default; -$link-muted-hover-color: $global-color !default; -$link-text-hover-color: $global-muted-color !default; -$link-heading-hover-color: $global-primary-background !default; -$link-heading-hover-text-decoration: none !default; -$inverse-link-muted-color: $inverse-global-muted-color !default; -$inverse-link-muted-hover-color: $inverse-global-color !default; -$inverse-link-text-hover-color: $inverse-global-muted-color !default; -$inverse-link-heading-hover-color: $inverse-global-primary-background !default; -$list-margin-top: $global-small-margin !default; -$list-padding-left: 30px !default; -$list-marker-height: ($global-line-height * 1em) !default; -$list-muted-color: $global-muted-color !default; -$list-emphasis-color: $global-emphasis-color !default; -$list-primary-color: $global-primary-background !default; -$list-secondary-color: $global-secondary-background !default; -$list-bullet-icon-color: $global-color !default; -$list-divider-margin-top: $global-small-margin !default; -$list-divider-border-width: $global-border-width !default; -$list-divider-border: $global-border !default; -$list-striped-padding-vertical: $global-small-margin !default; -$list-striped-padding-horizontal: $global-small-margin !default; -$list-striped-background: $global-muted-background !default; -$list-large-margin-top: $global-margin !default; -$list-large-divider-margin-top: $global-margin !default; -$list-large-striped-padding-vertical: $global-margin !default; -$list-large-striped-padding-horizontal: $global-small-margin !default; -$internal-list-bullet-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%226%22%20height%3D%226%22%20viewBox%3D%220%200%206%206%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Ccircle%20fill%3D%22#000%22%20cx%3D%223%22%20cy%3D%223%22%20r%3D%223%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$inverse-list-muted-color: $inverse-global-muted-color !default; -$inverse-list-emphasis-color: $inverse-global-emphasis-color !default; -$inverse-list-primary-color: $inverse-global-primary-background !default; -$inverse-list-secondary-color: $inverse-global-primary-background !default; -$inverse-list-divider-border: $inverse-global-border !default; -$inverse-list-striped-background: $inverse-global-muted-background !default; -$inverse-list-bullet-icon-color: $inverse-global-color !default; -$margin-margin: $global-margin !default; -$margin-small-margin: $global-small-margin !default; -$margin-medium-margin: $global-medium-margin !default; -$margin-large-margin: $global-medium-margin !default; -$margin-large-margin-l: $global-large-margin !default; -$margin-xlarge-margin: $global-large-margin !default; -$global-xlarge-margin: 140px !default; -$margin-xlarge-margin-l: $global-xlarge-margin !default; -$marker-padding: 5px !default; -$marker-background: $global-secondary-background !default; -$marker-color: $global-inverse-color !default; -$marker-hover-color: $global-inverse-color !default; -$inverse-marker-background: $global-muted-background !default; -$inverse-marker-color: $global-color !default; -$inverse-marker-hover-color: $global-color !default; -$modal-z-index: $global-z-index + 10 !default; -$modal-background: rgba(0,0,0,0.6) !default; -$modal-padding-horizontal: 15px !default; -$modal-padding-horizontal-s: $global-gutter !default; -$modal-padding-horizontal-m: $global-medium-gutter !default; -$modal-padding-vertical: $modal-padding-horizontal !default; -$modal-padding-vertical-s: 50px !default; -$modal-dialog-width: 600px !default; -$modal-dialog-background: $global-background !default; -$modal-container-width: 1200px !default; -$modal-body-padding-horizontal: $global-gutter !default; -$modal-body-padding-vertical: $global-gutter !default; -$modal-header-padding-horizontal: $global-gutter !default; -$modal-header-padding-vertical: ($modal-header-padding-horizontal / 2) !default; -$modal-header-background: $modal-dialog-background !default; -$modal-footer-padding-horizontal: $global-gutter !default; -$modal-footer-padding-vertical: ($modal-footer-padding-horizontal / 2) !default; -$modal-footer-background: $modal-dialog-background !default; -$modal-title-font-size: $global-xlarge-font-size !default; -$modal-title-line-height: 1.3 !default; -$modal-close-position: $global-small-margin !default; -$modal-close-padding: 5px !default; -$modal-close-outside-position: 0 !default; -$modal-close-outside-translate: 100% !default; -$modal-close-outside-color: lighten($global-inverse-color, 20%) !default; -$modal-close-outside-hover-color: $global-inverse-color !default; -$nav-item-padding-vertical: 5px !default; -$nav-item-padding-horizontal: 0 !default; -$nav-sublist-padding-vertical: 5px !default; -$nav-sublist-padding-left: 15px !default; -$nav-sublist-deeper-padding-left: 15px !default; -$nav-sublist-item-padding-vertical: 2px !default; -$nav-parent-icon-width: ($global-line-height * 1em) !default; -$nav-parent-icon-height: $nav-parent-icon-width !default; -$nav-parent-icon-color: $global-color !default; -$nav-header-padding-vertical: $nav-item-padding-vertical !default; -$nav-header-padding-horizontal: $nav-item-padding-horizontal !default; -$nav-header-font-size: $global-small-font-size !default; -$nav-header-text-transform: uppercase !default; -$nav-header-margin-top: $global-margin !default; -$nav-divider-margin-vertical: 5px !default; -$nav-divider-margin-horizontal: 0 !default; -$nav-default-item-color: $global-muted-color !default; -$nav-default-item-hover-color: $global-color !default; -$nav-default-item-active-color: $global-emphasis-color !default; -$nav-default-header-color: $global-emphasis-color !default; -$nav-default-divider-border-width: $global-border-width !default; -$nav-default-divider-border: $global-border !default; -$nav-default-sublist-item-color: $global-muted-color !default; -$nav-default-sublist-item-hover-color: $global-color !default; -$nav-default-sublist-item-active-color: $global-emphasis-color !default; -$nav-primary-item-font-size: $global-large-font-size !default; -$nav-primary-item-line-height: $global-line-height !default; -$nav-primary-item-color: $global-muted-color !default; -$nav-primary-item-hover-color: $global-color !default; -$nav-primary-item-active-color: $global-emphasis-color !default; -$nav-primary-header-color: $global-emphasis-color !default; -$nav-primary-divider-border-width: $global-border-width !default; -$nav-primary-divider-border: $global-border !default; -$nav-primary-sublist-item-color: $global-muted-color !default; -$nav-primary-sublist-item-hover-color: $global-color !default; -$nav-primary-sublist-item-active-color: $global-emphasis-color !default; -$nav-dividers-margin-top: 0 !default; -$nav-dividers-border-width: $global-border-width !default; -$nav-dividers-border: $global-border !default; -$internal-nav-parent-close-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolyline%20fill%3D%22none%22%20stroke%3D%22#000%22%20stroke-width%3D%221.1%22%20points%3D%2210%201%204%207%2010%2013%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$internal-nav-parent-open-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolyline%20fill%3D%22none%22%20stroke%3D%22#000%22%20stroke-width%3D%221.1%22%20points%3D%221%204%207%2010%2013%204%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$inverse-nav-parent-icon-color: $inverse-global-color !default; -$inverse-nav-default-item-color: $inverse-global-muted-color !default; -$inverse-nav-default-item-hover-color: $inverse-global-color !default; -$inverse-nav-default-item-active-color: $inverse-global-emphasis-color !default; -$inverse-nav-default-header-color: $inverse-global-emphasis-color !default; -$inverse-nav-default-divider-border: $inverse-global-border !default; -$inverse-nav-default-sublist-item-color: $inverse-global-muted-color !default; -$inverse-nav-default-sublist-item-hover-color: $inverse-global-color !default; -$inverse-nav-default-sublist-item-active-color: $inverse-global-emphasis-color !default; -$inverse-nav-primary-item-color: $inverse-global-muted-color !default; -$inverse-nav-primary-item-hover-color: $inverse-global-color !default; -$inverse-nav-primary-item-active-color: $inverse-global-emphasis-color !default; -$inverse-nav-primary-header-color: $inverse-global-emphasis-color !default; -$inverse-nav-primary-divider-border: $inverse-global-border !default; -$inverse-nav-primary-sublist-item-color: $inverse-global-muted-color !default; -$inverse-nav-primary-sublist-item-hover-color: $inverse-global-color !default; -$inverse-nav-primary-sublist-item-active-color: $inverse-global-emphasis-color !default; -$inverse-nav-dividers-border: $inverse-global-border !default; -$navbar-background: $global-muted-background !default; -$navbar-color-mode: none !default; -$navbar-nav-item-height: 80px !default; -$navbar-nav-item-padding-horizontal: 15px !default; -$navbar-nav-item-color: $global-muted-color !default; -$navbar-nav-item-font-size: $global-small-font-size !default; -$navbar-nav-item-font-family: $global-font-family !default; -$navbar-nav-item-hover-color: $global-color !default; -$navbar-nav-item-onclick-color: $global-emphasis-color !default; -$navbar-nav-item-active-color: $global-emphasis-color !default; -$navbar-item-color: $global-color !default; -$navbar-toggle-color: $global-muted-color !default; -$navbar-toggle-hover-color: $global-color !default; -$navbar-subtitle-font-size: $global-small-font-size !default; -$navbar-dropdown-z-index: $global-z-index + 20 !default; -$navbar-dropdown-width: 200px !default; -$navbar-dropdown-margin: 15px !default; -$navbar-dropdown-padding: 25px !default; -$navbar-dropdown-background: $global-background !default; -$navbar-dropdown-color: $global-color !default; -$navbar-dropdown-grid-gutter-horizontal: ($navbar-dropdown-padding * 2) !default; -$navbar-dropdown-grid-gutter-vertical: $navbar-dropdown-grid-gutter-horizontal !default; -$navbar-dropdown-dropbar-margin-top: 0 !default; -$navbar-dropdown-dropbar-margin-bottom: $navbar-dropdown-dropbar-margin-top !default; -$navbar-dropdown-nav-item-color: $global-muted-color !default; -$navbar-dropdown-nav-item-hover-color: $global-color !default; -$navbar-dropdown-nav-item-active-color: $global-emphasis-color !default; -$navbar-dropdown-nav-header-color: $global-emphasis-color !default; -$navbar-dropdown-nav-divider-border-width: $global-border-width !default; -$navbar-dropdown-nav-divider-border: $global-border !default; -$navbar-dropdown-nav-sublist-item-color: $global-muted-color !default; -$navbar-dropdown-nav-sublist-item-hover-color: $global-color !default; -$navbar-dropdown-nav-sublist-item-active-color: $global-emphasis-color !default; -$navbar-dropbar-background: $navbar-dropdown-background !default; -$navbar-dropbar-z-index: $global-z-index - 20 !default; -$inverse-navbar-nav-item-color: $inverse-global-muted-color !default; -$inverse-navbar-nav-item-hover-color: $inverse-global-color !default; -$inverse-navbar-nav-item-onclick-color: $inverse-global-emphasis-color !default; -$inverse-navbar-nav-item-active-color: $inverse-global-emphasis-color !default; -$inverse-navbar-item-color: $inverse-global-color !default; -$inverse-navbar-toggle-color: $inverse-global-muted-color !default; -$inverse-navbar-toggle-hover-color: $inverse-global-color !default; -$notification-position: 10px !default; -$notification-z-index: $global-z-index + 40 !default; -$notification-width: 350px !default; -$notification-message-margin-top: 10px !default; -$notification-message-padding: $global-small-gutter !default; -$notification-message-background: $global-muted-background !default; -$notification-message-color: $global-color !default; -$notification-message-font-size: $global-medium-font-size !default; -$notification-message-line-height: 1.4 !default; -$notification-close-top: $notification-message-padding + 5px !default; -$notification-close-right: $notification-message-padding !default; -$notification-message-primary-color: $global-primary-background !default; -$notification-message-success-color: $global-success-background !default; -$notification-message-warning-color: $global-warning-background !default; -$notification-message-danger-color: $global-danger-background !default; -$offcanvas-z-index: $global-z-index !default; -$offcanvas-bar-width: 270px !default; -$offcanvas-bar-padding-vertical: $global-margin !default; -$offcanvas-bar-padding-horizontal: $global-margin !default; -$offcanvas-bar-background: $global-secondary-background !default; -$offcanvas-bar-color-mode: light !default; -$offcanvas-bar-width-m: 350px !default; -$offcanvas-bar-padding-vertical-m: $global-medium-gutter !default; -$offcanvas-bar-padding-horizontal-m: $global-medium-gutter !default; -$offcanvas-close-position: 20px !default; -$offcanvas-close-padding: 5px !default; -$offcanvas-overlay-background: rgba(0,0,0,0.1) !default; -$overlay-padding-horizontal: $global-gutter !default; -$overlay-padding-vertical: $global-gutter !default; -$overlay-default-background: rgba($global-background, 0.8) !default; -$overlay-primary-background: rgba($global-secondary-background, 0.8) !default; -$overlay-primary-color-mode: light !default; -$padding-padding: $global-gutter !default; -$padding-padding-l: $global-medium-gutter !default; -$padding-small-padding: $global-small-gutter !default; -$padding-large-padding: $global-gutter !default; -$padding-large-padding-l: $global-large-gutter !default; -$pagination-margin-horizontal: 0 !default; -$pagination-item-padding-vertical: 5px !default; -$pagination-item-padding-horizontal: 10px !default; -$pagination-item-color: $global-muted-color !default; -$pagination-item-hover-color: $global-color !default; -$pagination-item-hover-text-decoration: none !default; -$pagination-item-active-color: $global-color !default; -$pagination-item-disabled-color: $global-muted-color !default; -$inverse-pagination-item-color: $inverse-global-muted-color !default; -$inverse-pagination-item-hover-color: $inverse-global-color !default; -$inverse-pagination-item-active-color: $inverse-global-color !default; -$inverse-pagination-item-disabled-color: $inverse-global-muted-color !default; -$placeholder-margin-vertical: $global-margin !default; -$placeholder-padding-vertical: $global-gutter !default; -$placeholder-padding-horizontal: $global-gutter !default; -$placeholder-background: transparent !default; -$position-small-margin: $global-small-gutter !default; -$position-medium-margin: $global-gutter !default; -$position-large-margin: $global-gutter !default; -$position-large-margin-l: 50px !default; -$progress-height: 15px !default; -$progress-margin-vertical: $global-margin !default; -$progress-background: $global-muted-background !default; -$progress-bar-background: $global-primary-background !default; -$search-color: $global-color !default; -$search-placeholder-color: $global-muted-color !default; -$search-icon-color: $global-muted-color !default; -$search-default-width: 240px !default; -$search-default-height: $global-control-height !default; -$search-default-padding-horizontal: 10px !default; -$search-default-background: transparent !default; -$search-default-focus-background: darken($search-default-background, 5%) !default; -$search-default-icon-width: $global-control-height !default; -$search-navbar-width: 400px !default; -$search-navbar-height: 40px !default; -$search-navbar-background: transparent !default; -$search-navbar-font-size: $global-large-font-size !default; -$search-navbar-icon-width: 40px !default; -$search-large-width: 500px !default; -$search-large-height: 80px !default; -$search-large-background: transparent !default; -$search-large-font-size: $global-2xlarge-font-size !default; -$search-large-icon-width: 80px !default; -$search-toggle-color: $global-muted-color !default; -$search-toggle-hover-color: $global-color !default; -$inverse-search-color: $inverse-global-color !default; -$inverse-search-placeholder-color: $inverse-global-muted-color !default; -$inverse-search-icon-color: $inverse-global-muted-color !default; -$inverse-search-default-background: transparent !default; -$inverse-search-default-focus-background: fadein($inverse-search-default-background, 5%) !default; -$inverse-search-navbar-background: transparent !default; -$inverse-search-large-background: transparent !default; -$inverse-search-toggle-color: $inverse-global-muted-color !default; -$inverse-search-toggle-hover-color: $inverse-global-color !default; -$section-padding-vertical: $global-medium-margin !default; -$section-padding-vertical-m: $global-large-margin !default; -$section-xsmall-padding-vertical: $global-margin !default; -$section-small-padding-vertical: $global-medium-margin !default; -$section-large-padding-vertical: $global-large-margin !default; -$section-large-padding-vertical-m: $global-xlarge-margin !default; -$section-xlarge-padding-vertical: $global-xlarge-margin !default; -$section-xlarge-padding-vertical-m: ($global-large-margin + $global-xlarge-margin) !default; -$section-default-background: $global-background !default; -$section-muted-background: $global-muted-background !default; -$section-primary-background: $global-primary-background !default; -$section-primary-color-mode: light !default; -$section-secondary-background: $global-secondary-background !default; -$section-secondary-color-mode: light !default; -$slidenav-padding-vertical: 5px !default; -$slidenav-padding-horizontal: 10px !default; -$slidenav-color: rgba($global-color, 0.5) !default; -$slidenav-hover-color: rgba($global-color, 0.9) !default; -$slidenav-active-color: rgba($global-color, 0.5) !default; -$slidenav-large-padding-vertical: 10px !default; -$slidenav-large-padding-horizontal: $slidenav-large-padding-vertical !default; -$inverse-slidenav-color: rgba($inverse-global-color, 0.7) !default; -$inverse-slidenav-hover-color: rgba($inverse-global-color, 0.95) !default; -$inverse-slidenav-active-color: rgba($inverse-global-color, 0.7) !default; -$slider-container-margin-top: -11px !default; -$slider-container-margin-bottom: -39px !default; -$slider-container-margin-left: -25px !default; -$slider-container-margin-right: -25px !default; -$sortable-dragged-z-index: $global-z-index + 50 !default; -$sortable-placeholder-opacity: 0 !default; -$sortable-empty-height: 50px !default; -$spinner-size: 30px !default; -$spinner-stroke-width: 1 !default; -$spinner-radius: floor(($spinner-size - $spinner-stroke-width) / 2) !default; -$spinner-circumference: round(2 * 3.141 * $spinner-radius) !default; -$spinner-duration: 1.4s !default; -$sticky-z-index: $global-z-index - 20 !default; -$sticky-animation-duration: 0.2s !default; -$sticky-reverse-animation-duration: 0.2s !default; -$subnav-margin-horizontal: 20px !default; -$subnav-item-color: $global-muted-color !default; -$subnav-item-hover-color: $global-color !default; -$subnav-item-hover-text-decoration: none !default; -$subnav-item-active-color: $global-emphasis-color !default; -$subnav-divider-margin-horizontal: $subnav-margin-horizontal !default; -$subnav-divider-border-height: 1.5em !default; -$subnav-divider-border-width: $global-border-width !default; -$subnav-divider-border: $global-border !default; -$subnav-pill-item-padding-vertical: 5px !default; -$subnav-pill-item-padding-horizontal: 10px !default; -$subnav-pill-item-background: transparent !default; -$subnav-pill-item-color: $subnav-item-color !default; -$subnav-pill-item-hover-background: $global-muted-background !default; -$subnav-pill-item-hover-color: $global-color !default; -$subnav-pill-item-onclick-background: $subnav-pill-item-hover-background !default; -$subnav-pill-item-onclick-color: $subnav-pill-item-hover-color !default; -$subnav-pill-item-active-background: $global-primary-background !default; -$subnav-pill-item-active-color: $global-inverse-color !default; -$subnav-item-disabled-color: $global-muted-color !default; -$inverse-subnav-item-color: $inverse-global-muted-color !default; -$inverse-subnav-item-hover-color: $inverse-global-color !default; -$inverse-subnav-item-active-color: $inverse-global-emphasis-color !default; -$inverse-subnav-divider-border: $inverse-global-border !default; -$inverse-subnav-pill-item-background: transparent !default; -$inverse-subnav-pill-item-color: $inverse-global-muted-color !default; -$inverse-subnav-pill-item-hover-background: $inverse-global-muted-background !default; -$inverse-subnav-pill-item-hover-color: $inverse-global-color !default; -$inverse-subnav-pill-item-onclick-background: $inverse-subnav-pill-item-hover-background !default; -$inverse-subnav-pill-item-onclick-color: $inverse-subnav-pill-item-hover-color !default; -$inverse-subnav-pill-item-active-background: $inverse-global-primary-background !default; -$inverse-subnav-pill-item-active-color: $inverse-global-inverse-color !default; -$inverse-subnav-item-disabled-color: $inverse-global-muted-color !default; -$tab-margin-horizontal: 20px !default; -$tab-item-padding-horizontal: 10px !default; -$tab-item-padding-vertical: 5px !default; -$tab-item-color: $global-muted-color !default; -$tab-item-hover-color: $global-color !default; -$tab-item-hover-text-decoration: none !default; -$tab-item-active-color: $global-emphasis-color !default; -$tab-item-disabled-color: $global-muted-color !default; -$inverse-tab-item-color: $inverse-global-muted-color !default; -$inverse-tab-item-hover-color: $inverse-global-color !default; -$inverse-tab-item-active-color: $inverse-global-emphasis-color !default; -$inverse-tab-item-disabled-color: $inverse-global-muted-color !default; -$table-margin-vertical: $global-margin !default; -$table-cell-padding-vertical: 16px !default; -$table-cell-padding-horizontal: 12px !default; -$table-header-cell-font-size: $global-small-font-size !default; -$table-header-cell-font-weight: normal !default; -$table-header-cell-color: $global-muted-color !default; -$table-footer-font-size: $global-small-font-size !default; -$table-caption-font-size: $global-small-font-size !default; -$table-caption-color: $global-muted-color !default; -$table-row-active-background: #ffd !default; -$table-divider-border-width: $global-border-width !default; -$table-divider-border: $global-border !default; -$table-striped-row-background: $global-muted-background !default; -$table-hover-row-background: $table-row-active-background !default; -$table-small-cell-padding-vertical: 10px !default; -$table-small-cell-padding-horizontal: 12px !default; -$table-large-cell-padding-vertical: 22px !default; -$table-large-cell-padding-horizontal: 12px !default; -$table-expand-min-width: 150px !default; -$inverse-table-header-cell-color: $inverse-global-color !default; -$inverse-table-caption-color: $inverse-global-muted-color !default; -$inverse-table-row-active-background: fade-out($inverse-global-muted-background, 0.02) !default; -$inverse-table-divider-border: $inverse-global-border !default; -$inverse-table-striped-row-background: $inverse-global-muted-background !default; -$inverse-table-hover-row-background: $inverse-table-row-active-background !default; -$text-lead-font-size: $global-large-font-size !default; -$text-lead-line-height: 1.5 !default; -$text-lead-color: $global-emphasis-color !default; -$text-meta-font-size: $global-small-font-size !default; -$text-meta-line-height: 1.4 !default; -$text-meta-color: $global-muted-color !default; -$text-small-font-size: $global-small-font-size !default; -$text-small-line-height: 1.5 !default; -$text-large-font-size: $global-large-font-size !default; -$text-large-line-height: 1.5 !default; -$text-muted-color: $global-muted-color !default; -$text-emphasis-color: $global-emphasis-color !default; -$text-primary-color: $global-primary-background !default; -$text-secondary-color: $global-secondary-background !default; -$text-success-color: $global-success-background !default; -$text-warning-color: $global-warning-background !default; -$text-danger-color: $global-danger-background !default; -$text-background-color: $global-primary-background !default; -$inverse-text-lead-color: $inverse-global-color !default; -$inverse-text-meta-color: $inverse-global-muted-color !default; -$inverse-text-muted-color: $inverse-global-muted-color !default; -$inverse-text-emphasis-color: $inverse-global-emphasis-color !default; -$inverse-text-primary-color: $inverse-global-primary-background !default; -$inverse-text-secondary-color: $inverse-global-primary-background !default; -$thumbnav-margin-horizontal: 15px !default; -$thumbnav-margin-vertical: $thumbnav-margin-horizontal !default; -$tile-padding-horizontal: 15px !default; -$tile-padding-horizontal-s: $global-gutter !default; -$tile-padding-horizontal-m: $global-medium-gutter !default; -$tile-padding-vertical: $global-medium-margin !default; -$tile-padding-vertical-m: $global-large-margin !default; -$tile-xsmall-padding-vertical: $global-margin !default; -$tile-small-padding-vertical: $global-medium-margin !default; -$tile-large-padding-vertical: $global-large-margin !default; -$tile-large-padding-vertical-m: $global-xlarge-margin !default; -$tile-xlarge-padding-vertical: $global-xlarge-margin !default; -$tile-xlarge-padding-vertical-m: ($global-large-margin + $global-xlarge-margin) !default; -$tile-default-background: $global-background !default; -$tile-muted-background: $global-muted-background !default; -$tile-primary-background: $global-primary-background !default; -$tile-primary-color-mode: light !default; -$tile-secondary-background: $global-secondary-background !default; -$tile-secondary-color-mode: light !default; -$tooltip-z-index: $global-z-index + 30 !default; -$tooltip-max-width: 200px !default; -$tooltip-padding-vertical: 3px !default; -$tooltip-padding-horizontal: 6px !default; -$tooltip-background: #666 !default; -$tooltip-border-radius: 2px !default; -$tooltip-color: $global-inverse-color !default; -$tooltip-font-size: 12px !default; -$tooltip-margin: 10px !default; -$totop-padding: 5px !default; -$totop-color: $global-muted-color !default; -$totop-hover-color: $global-color !default; -$totop-active-color: $global-emphasis-color !default; -$inverse-totop-color: $inverse-global-muted-color !default; -$inverse-totop-hover-color: $inverse-global-color !default; -$inverse-totop-active-color: $inverse-global-emphasis-color !default; -$transition-duration: 0.3s !default; -$transition-scale: 1.03 !default; -$transition-slide-small-translate: 10px !default; -$transition-slide-medium-translate: 50px !default; -$transition-slow-duration: 0.7s !default; -$panel-scrollable-height: 170px !default; -$panel-scrollable-padding: 10px !default; -$panel-scrollable-border-width: $global-border-width !default; -$panel-scrollable-border: $global-border !default; -$border-rounded-border-radius: 5px !default; -$box-shadow-duration: 0.1s !default; -$box-shadow-bottom-height: 30px !default; -$box-shadow-bottom-border-radius: 100% !default; -$box-shadow-bottom-background: #444 !default; -$box-shadow-bottom-blur: 20px !default; -$dropcap-margin-right: 10px !default; -$dropcap-font-size: (($global-line-height * 3) * 1em) !default; -$logo-font-size: $global-large-font-size !default; -$logo-font-family: $global-font-family !default; -$logo-color: $global-color !default; -$logo-hover-color: $global-color !default; -$dragover-box-shadow: 0 0 20px rgba(100,100,100,0.3) !default; -$inverse-logo-color: $inverse-global-color !default; -$inverse-logo-hover-color: $inverse-global-color !default; -$deprecated: false !default; -$breakpoint-small: 640px !default; -$breakpoint-medium: 960px !default; -$breakpoint-large: 1200px !default; -$breakpoint-xlarge: 1600px !default; -$breakpoint-xsmall-max: ($breakpoint-small - 1) !default; -$breakpoint-small-max: ($breakpoint-medium - 1) !default; -$breakpoint-medium-max: ($breakpoint-large - 1) !default; -$breakpoint-large-max: ($breakpoint-xlarge - 1) !default; -$global-small-box-shadow: 0 2px 8px rgba(0,0,0,0.08) !default; -$global-medium-box-shadow: 0 5px 15px rgba(0,0,0,0.08) !default; -$global-large-box-shadow: 0 14px 25px rgba(0,0,0,0.16) !default; -$global-xlarge-box-shadow: 0 28px 50px rgba(0,0,0,0.16) !default; -$width-small-width: 150px !default; -$width-medium-width: 300px !default; -$width-large-width: 450px !default; -$width-xlarge-width: 600px !default; -$width-2xlarge-width: 750px !default; -$accordion-icon-margin-left: 10px !default; -$accordion-icon-color: $global-color !default; -$internal-accordion-open-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2213%22%20height%3D%2213%22%20viewBox%3D%220%200%2013%2013%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Crect%20fill%3D%22#000%22%20width%3D%2213%22%20height%3D%221%22%20x%3D%220%22%20y%3D%226%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$internal-accordion-close-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2213%22%20height%3D%2213%22%20viewBox%3D%220%200%2013%2013%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Crect%20fill%3D%22#000%22%20width%3D%2213%22%20height%3D%221%22%20x%3D%220%22%20y%3D%226%22%20%2F%3E%0A%20%20%20%20%3Crect%20fill%3D%22#000%22%20width%3D%221%22%20height%3D%2213%22%20x%3D%226%22%20y%3D%220%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$alert-close-opacity: 0.4 !default; -$alert-close-hover-opacity: 0.8 !default; -$article-meta-link-color: $article-meta-color !default; -$article-meta-link-hover-color: $global-color !default; -$base-code-padding-horizontal: 6px !default; -$base-code-padding-vertical: 2px !default; -$base-code-background: $global-muted-background !default; -$base-blockquote-color: $global-emphasis-color !default; -$base-blockquote-footer-color: $global-color !default; -$base-pre-padding: 10px !default; -$base-pre-background: $global-background !default; -$base-pre-border-width: $global-border-width !default; -$base-pre-border: $global-border !default; -$base-pre-border-radius: 3px !default; -$inverse-base-blockquote-color: $inverse-global-emphasis-color !default; -$inverse-base-blockquote-footer-color: $inverse-global-color !default; -$button-text-transform: uppercase !default; -$button-default-border: $global-border !default; -$button-default-hover-border: darken($global-border, 20%) !default; -$button-default-active-border: darken($global-border, 30%) !default; -$button-disabled-border: $global-border !default; -$button-text-border-width: $global-border-width !default; -$button-text-border: $button-text-hover-color !default; -$card-badge-border-radius: 2px !default; -$card-badge-text-transform: uppercase !default; -$card-hover-box-shadow: $global-large-box-shadow !default; -$card-default-box-shadow: $global-medium-box-shadow !default; -$card-default-hover-box-shadow: $global-large-box-shadow !default; -$card-default-header-border-width: $global-border-width !default; -$card-default-header-border: $global-border !default; -$card-default-footer-border-width: $global-border-width !default; -$card-default-footer-border: $global-border !default; -$card-primary-box-shadow: $global-medium-box-shadow !default; -$card-primary-hover-box-shadow: $global-large-box-shadow !default; -$card-secondary-box-shadow: $global-medium-box-shadow !default; -$card-secondary-hover-box-shadow: $global-large-box-shadow !default; -$comment-primary-padding: $global-gutter !default; -$comment-primary-background: $global-muted-background !default; -$description-list-term-font-size: $global-small-font-size !default; -$description-list-term-font-weight: normal !default; -$description-list-term-text-transform: uppercase !default; -$dotnav-item-border-width: 1px !default; -$dotnav-item-border: rgba($global-color, 0.4) !default; -$dotnav-item-hover-border: transparent !default; -$dotnav-item-onclick-border: transparent !default; -$dotnav-item-active-border: transparent !default; -$dropdown-nav-font-size: $global-small-font-size !default; -$dropdown-box-shadow: 0 5px 12px rgba(0,0,0,0.15) !default; -$form-range-thumb-border-width: $global-border-width !default; -$form-range-thumb-border: darken($global-border, 10%) !default; -$form-range-track-border-radius: 500px !default; -$form-border: $global-border !default; -$form-focus-border: $global-primary-background !default; -$form-disabled-border: $global-border !default; -$form-danger-border: $global-danger-background !default; -$form-success-border: $global-success-background !default; -$form-blank-focus-border: $global-border !default; -$form-blank-focus-border-style: dashed !default; -$form-radio-border-width: $global-border-width !default; -$form-radio-border: darken($global-border, 10%) !default; -$form-radio-focus-border: $global-primary-background !default; -$form-radio-checked-border: transparent !default; -$form-radio-disabled-border: $global-border !default; -$form-label-color: $global-emphasis-color !default; -$form-label-font-size: $global-small-font-size !default; -$inverse-form-label-color: $inverse-global-emphasis-color !default; -$subnav-item-font-size: $global-small-font-size !default; -$label-border-radius: 2px !default; -$label-text-transform: uppercase !default; -$list-striped-border-width: $global-border-width !default; -$list-striped-border: $global-border !default; -$modal-header-border-width: $global-border-width !default; -$modal-header-border: $global-border !default; -$modal-footer-border-width: $global-border-width !default; -$modal-footer-border: $global-border !default; -$modal-close-full-padding: $global-margin !default; -$modal-close-full-background: $modal-dialog-background !default; -$nav-default-font-size: $global-small-font-size !default; -$navbar-nav-item-text-transform: uppercase !default; -$navbar-dropdown-nav-font-size: $global-small-font-size !default; -$navbar-dropdown-box-shadow: 0 5px 12px rgba(0,0,0,0.15) !default; -$navbar-dropbar-box-shadow: 0 5px 7px rgba(0, 0, 0, 0.05) !default; -$navbar-dropdown-grid-divider-border-width: $global-border-width !default; -$navbar-dropdown-grid-divider-border: $navbar-dropdown-nav-divider-border !default; -$placeholder-border-width: $global-border-width !default; -$placeholder-border: $global-border !default; -$progress-border-radius: 500px !default; -$search-default-border-width: $global-border-width !default; -$search-default-border: $global-border !default; -$search-default-focus-border: $global-primary-background !default; -$subnav-item-text-transform: uppercase !default; -$tab-border-width: $global-border-width !default; -$tab-border: $global-border !default; -$tab-item-border-width: $global-border-width !default; -$tab-item-font-size: $global-small-font-size !default; -$tab-item-text-transform: uppercase !default; -$tab-item-active-border: $global-primary-background !default; -$inverse-tab-border: $inverse-global-border !default; -$table-striped-border-width: $global-border-width !default; -$table-striped-border: $global-border !default; -$text-meta-link-color: $text-meta-color !default; -$text-meta-link-hover-color: $global-color !default; -$thumbnav-item-background: rgba($global-background, 0.4) !default; -$thumbnav-item-hover-background: transparent !default; -$thumbnav-item-active-background: transparent !default; \ No newline at end of file diff --git a/docs/_sass/uikit/variables.scss b/docs/_sass/uikit/variables.scss deleted file mode 100644 index ce7cf9a02d..0000000000 --- a/docs/_sass/uikit/variables.scss +++ /dev/null @@ -1,1061 +0,0 @@ -$global-margin: 20px !default; -$accordion-item-margin-top: $global-margin !default; -$global-medium-font-size: 1.25rem !default; -$accordion-title-font-size: $global-medium-font-size !default; -$accordion-title-line-height: 1.4 !default; -$global-emphasis-color: #333 !default; -$accordion-title-color: $global-emphasis-color !default; -$global-color: #666 !default; -$accordion-title-hover-color: $global-color !default; -$accordion-content-margin-top: $global-margin !default; -$global-inverse-color: #fff !default; -$inverse-global-emphasis-color: $global-inverse-color !default; -$inverse-accordion-title-color: $inverse-global-emphasis-color !default; -$inverse-global-color: rgba($global-inverse-color, 0.7) !default; -$inverse-accordion-title-hover-color: $inverse-global-color !default; -$alert-margin-vertical: $global-margin !default; -$alert-padding: 15px !default; -$alert-padding-right: $alert-padding + 14px !default; -$global-muted-background: #f8f8f8 !default; -$alert-background: $global-muted-background !default; -$alert-color: $global-color !default; -$alert-close-top: $alert-padding + 5px !default; -$alert-close-right: $alert-padding !default; -$global-primary-background: #1e87f0 !default; -$alert-primary-background: lighten(mix(white, $global-primary-background, 40%), 20%) !default; -$alert-primary-color: $global-primary-background !default; -$global-success-background: #32d296 !default; -$alert-success-background: lighten(mix(white, $global-success-background, 40%), 25%) !default; -$alert-success-color: $global-success-background !default; -$global-warning-background: #faa05a !default; -$alert-warning-background: lighten(mix(white, $global-warning-background, 45%), 15%) !default; -$alert-warning-color: $global-warning-background !default; -$global-danger-background: #f0506e !default; -$alert-danger-background: lighten(mix(white, $global-danger-background, 40%), 20%) !default; -$alert-danger-color: $global-danger-background !default; -$global-gutter: 30px !default; -$align-margin-horizontal: $global-gutter !default; -$align-margin-vertical: $global-gutter !default; -$global-medium-gutter: 40px !default; -$align-margin-horizontal-l: $global-medium-gutter !default; -$animation-duration: 0.5s !default; -$animation-fade-duration: 0.8s !default; -$animation-stroke-duration: 2s !default; -$animation-kenburns-duration: 15s !default; -$animation-fast-duration: 0.1s !default; -$animation-slide-small-translate: 10px !default; -$animation-slide-medium-translate: 50px !default; -$global-large-margin: 70px !default; -$article-margin-top: $global-large-margin !default; -$global-2xlarge-font-size: 2.625rem !default; -$article-title-font-size-m: $global-2xlarge-font-size !default; -$article-title-font-size: $article-title-font-size-m * 0.85 !default; -$article-title-line-height: 1.2 !default; -$global-small-font-size: 0.875rem !default; -$article-meta-font-size: $global-small-font-size !default; -$article-meta-line-height: 1.4 !default; -$global-muted-color: #999 !default; -$article-meta-color: $global-muted-color !default; -$inverse-global-muted-color: rgba($global-inverse-color, 0.5) !default; -$inverse-article-meta-color: $inverse-global-muted-color !default; -$global-background: #fff !default; -$background-default-background: $global-background !default; -$background-muted-background: $global-muted-background !default; -$background-primary-background: $global-primary-background !default; -$global-secondary-background: #222 !default; -$background-secondary-background: $global-secondary-background !default; -$badge-size: 18px !default; -$badge-padding-vertical: 0 !default; -$badge-padding-horizontal: 5px !default; -$badge-border-radius: 500px !default; -$badge-background: $global-primary-background !default; -$badge-color: $global-inverse-color !default; -$badge-font-size: 11px !default; -$inverse-global-primary-background: $global-inverse-color !default; -$inverse-badge-background: $inverse-global-primary-background !default; -$inverse-global-inverse-color: $global-color !default; -$inverse-badge-color: $inverse-global-inverse-color !default; -$base-body-background: $global-background !default; -$global-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default; -$base-body-font-family: $global-font-family !default; -$base-body-font-weight: normal !default; -$global-font-size: 16px !default; -$base-body-font-size: $global-font-size !default; -$global-line-height: 1.5 !default; -$base-body-line-height: $global-line-height !default; -$base-body-color: $global-color !default; -$global-link-color: #1e87f0 !default; -$base-link-color: $global-link-color !default; -$base-link-text-decoration: none !default; -$global-link-hover-color: #0f6ecd !default; -$base-link-hover-color: $global-link-hover-color !default; -$base-link-hover-text-decoration: underline !default; -$base-strong-font-weight: bolder !default; -$base-code-font-size: $global-small-font-size !default; -$base-code-font-family: Consolas, monaco, monospace !default; -$base-code-color: $global-danger-background !default; -$base-em-color: $global-danger-background !default; -$base-ins-background: #ffd !default; -$base-ins-color: $global-color !default; -$base-mark-background: #ffd !default; -$base-mark-color: $global-color !default; -$base-quote-font-style: italic !default; -$base-small-font-size: 80% !default; -$base-margin-vertical: $global-margin !default; -$base-heading-font-family: $global-font-family !default; -$base-heading-font-weight: normal !default; -$base-heading-color: $global-emphasis-color !default; -$base-heading-text-transform: none !default; -$global-medium-margin: 40px !default; -$base-heading-margin-top: $global-medium-margin !default; -$base-h1-font-size-m: $global-2xlarge-font-size !default; -$base-h1-font-size: $base-h1-font-size-m * 0.85 !default; -$base-h1-line-height: 1.2 !default; -$global-xlarge-font-size: 2rem !default; -$base-h2-font-size-m: $global-xlarge-font-size !default; -$base-h2-font-size: $base-h2-font-size-m * 0.85 !default; -$base-h2-line-height: 1.3 !default; -$global-large-font-size: 1.5rem !default; -$base-h3-font-size: $global-large-font-size !default; -$base-h3-line-height: 1.4 !default; -$base-h4-font-size: $global-medium-font-size !default; -$base-h4-line-height: 1.4 !default; -$base-h5-font-size: $global-font-size !default; -$base-h5-line-height: 1.4 !default; -$base-h6-font-size: $global-small-font-size !default; -$base-h6-line-height: 1.4 !default; -$base-list-padding-left: 30px !default; -$base-hr-margin-vertical: $global-margin !default; -$global-border-width: 1px !default; -$base-hr-border-width: $global-border-width !default; -$global-border: #e5e5e5 !default; -$base-hr-border: $global-border !default; -$base-blockquote-font-size: $global-medium-font-size !default; -$base-blockquote-line-height: 1.5 !default; -$base-blockquote-font-style: italic !default; -$base-blockquote-margin-vertical: $global-margin !default; -$global-small-margin: 10px !default; -$base-blockquote-footer-margin-top: $global-small-margin !default; -$base-blockquote-footer-font-size: $global-small-font-size !default; -$base-blockquote-footer-line-height: 1.5 !default; -$base-pre-font-size: $global-small-font-size !default; -$base-pre-line-height: 1.5 !default; -$base-pre-font-family: $base-code-font-family !default; -$base-pre-color: $global-color !default; -$base-selection-background: #39f !default; -$base-selection-color: $global-inverse-color !default; -$inverse-base-color: $inverse-global-color !default; -$inverse-base-link-color: $inverse-global-emphasis-color !default; -$inverse-base-link-hover-color: $inverse-global-emphasis-color !default; -$inverse-base-code-color: $inverse-global-color !default; -$inverse-base-em-color: $inverse-global-emphasis-color !default; -$inverse-base-heading-color: $inverse-global-emphasis-color !default; -$inverse-global-border: rgba($global-inverse-color, 0.2) !default; -$inverse-base-hr-border: $inverse-global-border !default; -$breadcrumb-item-font-size: $global-small-font-size !default; -$breadcrumb-item-color: $global-muted-color !default; -$breadcrumb-item-hover-color: $global-color !default; -$breadcrumb-item-hover-text-decoration: none !default; -$breadcrumb-item-active-color: $global-color !default; -$breadcrumb-divider: "/" !default; -$breadcrumb-divider-margin-horizontal: 20px !default; -$breadcrumb-divider-font-size: $breadcrumb-item-font-size !default; -$breadcrumb-divider-color: $global-muted-color !default; -$inverse-breadcrumb-item-color: $inverse-global-muted-color !default; -$inverse-breadcrumb-item-hover-color: $inverse-global-color !default; -$inverse-breadcrumb-item-active-color: $inverse-global-color !default; -$inverse-breadcrumb-divider-color: $inverse-global-muted-color !default; -$global-control-height: 40px !default; -$button-line-height: $global-control-height !default; -$global-control-small-height: 30px !default; -$button-small-line-height: $global-control-small-height !default; -$global-control-large-height: 55px !default; -$button-large-line-height: $global-control-large-height !default; -$button-font-size: $global-font-size !default; -$button-small-font-size: $global-small-font-size !default; -$button-large-font-size: $global-medium-font-size !default; -$button-padding-horizontal: $global-gutter !default; -$global-small-gutter: 15px !default; -$button-small-padding-horizontal: $global-small-gutter !default; -$button-large-padding-horizontal: $global-medium-gutter !default; -$button-default-background: $global-muted-background !default; -$button-default-color: $global-emphasis-color !default; -$button-default-hover-background: darken($button-default-background, 5%) !default; -$button-default-hover-color: $global-emphasis-color !default; -$button-default-active-background: darken($button-default-background, 10%) !default; -$button-default-active-color: $global-emphasis-color !default; -$button-primary-background: $global-primary-background !default; -$button-primary-color: $global-inverse-color !default; -$button-primary-hover-background: darken($button-primary-background, 5%) !default; -$button-primary-hover-color: $global-inverse-color !default; -$button-primary-active-background: darken($button-primary-background, 10%) !default; -$button-primary-active-color: $global-inverse-color !default; -$button-secondary-background: $global-secondary-background !default; -$button-secondary-color: $global-inverse-color !default; -$button-secondary-hover-background: darken($button-secondary-background, 5%) !default; -$button-secondary-hover-color: $global-inverse-color !default; -$button-secondary-active-background: darken($button-secondary-background, 10%) !default; -$button-secondary-active-color: $global-inverse-color !default; -$button-danger-background: $global-danger-background !default; -$button-danger-color: $global-inverse-color !default; -$button-danger-hover-background: darken($button-danger-background, 5%) !default; -$button-danger-hover-color: $global-inverse-color !default; -$button-danger-active-background: darken($button-danger-background, 10%) !default; -$button-danger-active-color: $global-inverse-color !default; -$button-disabled-background: $global-muted-background !default; -$button-disabled-color: $global-muted-color !default; -$button-text-line-height: $global-line-height !default; -$button-text-color: $global-emphasis-color !default; -$button-text-hover-color: $global-muted-color !default; -$button-text-disabled-color: $global-muted-color !default; -$button-link-line-height: $global-line-height !default; -$button-link-color: $global-emphasis-color !default; -$button-link-hover-color: $global-muted-color !default; -$button-link-hover-text-decoration: none !default; -$button-link-disabled-color: $global-muted-color !default; -$inverse-button-default-background: $inverse-global-primary-background !default; -$inverse-button-default-color: $inverse-global-inverse-color !default; -$inverse-button-default-hover-background: darken($inverse-button-default-background, 5%) !default; -$inverse-button-default-hover-color: $inverse-global-inverse-color !default; -$inverse-button-default-active-background: darken($inverse-button-default-background, 10%) !default; -$inverse-button-default-active-color: $inverse-global-inverse-color !default; -$inverse-button-primary-background: $inverse-global-primary-background !default; -$inverse-button-primary-color: $inverse-global-inverse-color !default; -$inverse-button-primary-hover-background: darken($inverse-button-primary-background, 5%) !default; -$inverse-button-primary-hover-color: $inverse-global-inverse-color !default; -$inverse-button-primary-active-background: darken($inverse-button-primary-background, 10%) !default; -$inverse-button-primary-active-color: $inverse-global-inverse-color !default; -$inverse-button-secondary-background: $inverse-global-primary-background !default; -$inverse-button-secondary-color: $inverse-global-inverse-color !default; -$inverse-button-secondary-hover-background: darken($inverse-button-secondary-background, 5%) !default; -$inverse-button-secondary-hover-color: $inverse-global-inverse-color !default; -$inverse-button-secondary-active-background: darken($inverse-button-secondary-background, 10%) !default; -$inverse-button-secondary-active-color: $inverse-global-inverse-color !default; -$inverse-button-text-color: $inverse-global-emphasis-color !default; -$inverse-button-text-hover-color: $inverse-global-muted-color !default; -$inverse-button-text-disabled-color: $inverse-global-muted-color !default; -$inverse-button-link-color: $inverse-global-emphasis-color !default; -$inverse-button-link-hover-color: $inverse-global-muted-color !default; -$card-body-padding-horizontal: $global-gutter !default; -$card-body-padding-vertical: $global-gutter !default; -$card-body-padding-horizontal-l: $global-medium-gutter !default; -$card-body-padding-vertical-l: $global-medium-gutter !default; -$card-header-padding-horizontal: $global-gutter !default; -$card-header-padding-vertical: round($global-gutter / 2) !default; -$card-header-padding-horizontal-l: $global-medium-gutter !default; -$card-header-padding-vertical-l: round($global-medium-gutter / 2) !default; -$card-footer-padding-horizontal: $global-gutter !default; -$card-footer-padding-vertical: ($global-gutter / 2) !default; -$card-footer-padding-horizontal-l: $global-medium-gutter !default; -$card-footer-padding-vertical-l: round($global-medium-gutter / 2) !default; -$card-title-font-size: $global-large-font-size !default; -$card-title-line-height: 1.4 !default; -$card-badge-top: 15px !default; -$card-badge-right: 15px !default; -$card-badge-height: 22px !default; -$card-badge-padding-horizontal: 10px !default; -$card-badge-background: $global-primary-background !default; -$card-badge-color: $global-inverse-color !default; -$card-badge-font-size: $global-small-font-size !default; -$card-hover-background: $global-muted-background !default; -$card-default-background: $global-muted-background !default; -$card-default-color: $global-color !default; -$card-default-title-color: $global-emphasis-color !default; -$card-default-hover-background: darken($card-default-background, 5%) !default; -$card-primary-background: $global-primary-background !default; -$card-primary-color: $global-inverse-color !default; -$card-primary-title-color: $card-primary-color !default; -$card-primary-hover-background: darken($card-primary-background, 5%) !default; -$card-primary-color-mode: light !default; -$card-secondary-background: $global-secondary-background !default; -$card-secondary-color: $global-inverse-color !default; -$card-secondary-title-color: $card-secondary-color !default; -$card-secondary-hover-background: darken($card-secondary-background, 5%) !default; -$card-secondary-color-mode: light !default; -$card-small-body-padding-horizontal: $global-margin !default; -$card-small-body-padding-vertical: $global-margin !default; -$card-small-header-padding-horizontal: $global-margin !default; -$card-small-header-padding-vertical: round($global-margin / 1.5) !default; -$card-small-footer-padding-horizontal: $global-margin !default; -$card-small-footer-padding-vertical: round($global-margin / 1.5) !default; -$global-large-gutter: 70px !default; -$card-large-body-padding-horizontal-l: $global-large-gutter !default; -$card-large-body-padding-vertical-l: $global-large-gutter !default; -$card-large-header-padding-horizontal-l: $global-large-gutter !default; -$card-large-header-padding-vertical-l: round($global-large-gutter / 2) !default; -$card-large-footer-padding-horizontal-l: $global-large-gutter !default; -$card-large-footer-padding-vertical-l: round($global-large-gutter / 2) !default; -$inverse-card-badge-background: $inverse-global-primary-background !default; -$inverse-card-badge-color: $inverse-global-inverse-color !default; -$close-color: $global-muted-color !default; -$close-hover-color: $global-color !default; -$inverse-close-color: $inverse-global-muted-color !default; -$inverse-close-hover-color: $inverse-global-color !default; -$column-gutter: $global-gutter !default; -$column-gutter-l: $global-medium-gutter !default; -$column-divider-rule-color: $global-border !default; -$column-divider-rule-width: 1px !default; -$inverse-column-divider-rule-color: $inverse-global-border !default; -$comment-header-margin-bottom: $global-margin !default; -$comment-title-font-size: $global-medium-font-size !default; -$comment-title-line-height: 1.4 !default; -$comment-meta-font-size: $global-small-font-size !default; -$comment-meta-line-height: 1.4 !default; -$comment-meta-color: $global-muted-color !default; -$comment-list-margin-top: $global-large-margin !default; -$comment-list-padding-left: 30px !default; -$comment-list-padding-left-m: 100px !default; -$container-max-width: 1200px !default; -$container-xsmall-max-width: 750px !default; -$container-small-max-width: 900px !default; -$container-large-max-width: 1400px !default; -$container-xlarge-max-width: 1600px !default; -$container-padding-horizontal: 15px !default; -$container-padding-horizontal-s: $global-gutter !default; -$container-padding-horizontal-m: $global-medium-gutter !default; -$countdown-number-line-height: 0.8 !default; -$countdown-number-font-size: 2rem !default; -$countdown-number-font-size-s: 4rem !default; -$countdown-number-font-size-m: 6rem !default; -$countdown-separator-line-height: 1.6 !default; -$countdown-separator-font-size: 1rem !default; -$countdown-separator-font-size-s: 2rem !default; -$countdown-separator-font-size-m: 3rem !default; -$description-list-term-color: $global-emphasis-color !default; -$description-list-term-margin-top: $global-margin !default; -$description-list-divider-term-margin-top: $global-margin !default; -$description-list-divider-term-border-width: $global-border-width !default; -$description-list-divider-term-border: $global-border !default; -$divider-margin-vertical: $global-margin !default; -$divider-icon-width: 50px !default; -$divider-icon-height: 20px !default; -$divider-icon-color: $global-border !default; -$divider-icon-line-top: 50% !default; -$divider-icon-line-width: 100% !default; -$divider-icon-line-border-width: $global-border-width !default; -$divider-icon-line-border: $global-border !default; -$internal-divider-icon-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Ccircle%20fill%3D%22none%22%20stroke%3D%22#000%22%20stroke-width%3D%222%22%20cx%3D%2210%22%20cy%3D%2210%22%20r%3D%227%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; -$divider-small-width: 100px !default; -$divider-small-border-width: $global-border-width !default; -$divider-small-border: $global-border !default; -$divider-vertical-height: 100px !default; -$divider-vertical-border-width: $global-border-width !default; -$divider-vertical-border: $global-border !default; -$inverse-divider-icon-color: $inverse-global-border !default; -$inverse-divider-icon-line-border: $inverse-global-border !default; -$inverse-divider-small-border: $inverse-global-border !default; -$inverse-divider-vertical-border: $inverse-global-border !default; -$dotnav-margin-horizontal: 12px !default; -$dotnav-margin-vertical: $dotnav-margin-horizontal !default; -$dotnav-item-width: 10px !default; -$dotnav-item-height: $dotnav-item-width !default; -$dotnav-item-border-radius: 50% !default; -$dotnav-item-background: rgba($global-color, 0.2) !default; -$dotnav-item-hover-background: rgba($global-color, 0.6) !default; -$dotnav-item-onclick-background: rgba($global-color, 0.2) !default; -$dotnav-item-active-background: rgba($global-color, 0.6) !default; -$inverse-dotnav-item-background: rgba($inverse-global-color, 0.5) !default; -$inverse-dotnav-item-hover-background: rgba($inverse-global-color, 0.9) !default; -$inverse-dotnav-item-onclick-background: rgba($inverse-global-color, 0.5) !default; -$inverse-dotnav-item-active-background: rgba($inverse-global-color, 0.9) !default; -$global-z-index: 1000 !default; -$drop-z-index: $global-z-index + 20 !default; -$drop-width: 300px !default; -$drop-margin: $global-margin !default; -$dropdown-z-index: $global-z-index + 20 !default; -$dropdown-min-width: 200px !default; -$dropdown-padding: 15px !default; -$dropdown-background: $global-muted-background !default; -$dropdown-color: $global-color !default; -$dropdown-margin: $global-small-margin !default; -$dropdown-nav-item-color: $global-muted-color !default; -$dropdown-nav-item-hover-color: $global-color !default; -$dropdown-nav-header-color: $global-emphasis-color !default; -$dropdown-nav-divider-border-width: $global-border-width !default; -$dropdown-nav-divider-border: $global-border !default; -$dropdown-nav-sublist-item-color: $global-muted-color !default; -$dropdown-nav-sublist-item-hover-color: $global-color !default; -$form-range-thumb-height: 15px !default; -$form-range-thumb-width: $form-range-thumb-height !default; -$form-range-thumb-border-radius: 500px !default; -$form-range-thumb-background: $global-color !default; -$form-range-track-height: 3px !default; -$form-range-track-background: darken($global-muted-background, 5%) !default; -$form-range-track-focus-background: darken($form-range-track-background, 5%) !default; -$form-height: $global-control-height !default; -$form-line-height: $form-height !default; -$form-padding-horizontal: 10px !default; -$form-padding-vertical: round($form-padding-horizontal * 0.6) !default; -$form-background: $global-muted-background !default; -$form-color: $global-color !default; -$form-focus-background: darken($form-background, 5%) !default; -$form-focus-color: $global-color !default; -$form-disabled-background: $global-muted-background !default; -$form-disabled-color: $global-muted-color !default; -$form-placeholder-color: $global-muted-color !default; -$form-small-height: $global-control-small-height !default; -$form-small-padding-horizontal: 8px !default; -$form-small-padding-vertical: round($form-small-padding-horizontal * 0.6) !default; -$form-small-line-height: $form-small-height !default; -$form-small-font-size: $global-small-font-size !default; -$form-large-height: $global-control-large-height !default; -$form-large-padding-horizontal: 12px !default; -$form-large-padding-vertical: round($form-large-padding-horizontal * 0.6) !default; -$form-large-line-height: $form-large-height !default; -$form-large-font-size: $global-medium-font-size !default; -$form-danger-color: $global-danger-background !default; -$form-success-color: $global-success-background !default; -$form-width-xsmall: 50px !default; -$form-width-small: 130px !default; -$form-width-medium: 200px !default; -$form-width-large: 500px !default; -$form-select-padding-right: 20px !default; -$form-select-icon-color: $global-color !default; -$form-select-option-color: #444 !default; -$form-select-disabled-icon-color: $global-muted-color !default; -$form-datalist-padding-right: 20px !default; -$form-datalist-icon-color: $global-color !default; -$form-radio-size: 16px !default; -$form-radio-margin-top: -4px !default; -$form-radio-background: darken($global-muted-background, 5%) !default; -$form-radio-focus-background: darken($form-radio-background, 5%) !default; -$form-radio-checked-background: $global-primary-background !default; -$form-radio-checked-icon-color: $global-inverse-color !default; -$form-radio-checked-focus-background: darken($global-primary-background, 10%) !default; -$form-radio-disabled-background: $global-muted-background !default; -$form-radio-disabled-icon-color: $global-muted-color !default; -$form-legend-font-size: $global-large-font-size !default; -$form-legend-line-height: 1.4 !default; -$form-stacked-margin-bottom: $global-small-margin !default; -$form-horizontal-label-width: 200px !default; -$form-horizontal-label-margin-top: 7px !default; -$form-horizontal-controls-margin-left: 215px !default; -$form-horizontal-controls-text-padding-top: 7px !default; -$form-icon-width: $form-height !default; -$form-icon-color: $global-muted-color !default; -$form-icon-hover-color: $global-color !default; -$internal-form-select-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2224%22%20height%3D%2216%22%20viewBox%3D%220%200%2024%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%201%209%206%2015%206%22%20%2F%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%2013%209%208%2015%208%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; -$internal-form-datalist-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2224%22%20height%3D%2216%22%20viewBox%3D%220%200%2024%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%2012%208%206%2016%206%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; -$internal-form-radio-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Ccircle%20fill%3D%22#000%22%20cx%3D%228%22%20cy%3D%228%22%20r%3D%222%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$internal-form-checkbox-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2211%22%20viewBox%3D%220%200%2014%2011%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolygon%20fill%3D%22#000%22%20points%3D%2212%201%205%207.5%202%205%201%205.5%205%2010%2013%201.5%22%20%2F%3E%0A%3C%2Fsvg%3E%0A" !default; -$internal-form-checkbox-indeterminate-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Crect%20fill%3D%22#000%22%20x%3D%223%22%20y%3D%228%22%20width%3D%2210%22%20height%3D%221%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$inverse-global-muted-background: rgba($global-inverse-color, 0.1) !default; -$inverse-form-background: $inverse-global-muted-background !default; -$inverse-form-color: $inverse-global-color !default; -$inverse-form-focus-background: fadein($inverse-form-background, 5%) !default; -$inverse-form-focus-color: $inverse-global-color !default; -$inverse-form-placeholder-color: $inverse-global-muted-color !default; -$inverse-form-select-icon-color: $inverse-global-color !default; -$inverse-form-datalist-icon-color: $inverse-global-color !default; -$inverse-form-radio-background: $inverse-global-muted-background !default; -$inverse-form-radio-focus-background: fadein($inverse-form-radio-background, 5%) !default; -$inverse-form-radio-checked-background: $inverse-global-primary-background !default; -$inverse-form-radio-checked-icon-color: $inverse-global-inverse-color !default; -$inverse-form-radio-checked-focus-background: fadein($inverse-global-primary-background, 10%) !default; -$inverse-form-icon-color: $inverse-global-muted-color !default; -$inverse-form-icon-hover-color: $inverse-global-color !default; -$grid-gutter-horizontal: $global-gutter !default; -$grid-gutter-vertical: $grid-gutter-horizontal !default; -$grid-gutter-horizontal-l: $global-medium-gutter !default; -$grid-gutter-vertical-l: $grid-gutter-horizontal-l !default; -$grid-small-gutter-horizontal: $global-small-gutter !default; -$grid-small-gutter-vertical: $grid-small-gutter-horizontal !default; -$grid-medium-gutter-horizontal: $global-gutter !default; -$grid-medium-gutter-vertical: $grid-medium-gutter-horizontal !default; -$grid-large-gutter-horizontal: $global-medium-gutter !default; -$grid-large-gutter-vertical: $grid-large-gutter-horizontal !default; -$grid-large-gutter-horizontal-l: $global-large-gutter !default; -$grid-large-gutter-vertical-l: $grid-large-gutter-horizontal-l !default; -$grid-divider-border-width: $global-border-width !default; -$grid-divider-border: $global-border !default; -$inverse-grid-divider-border: $inverse-global-border !default; -$heading-medium-font-size-l: 4rem !default; -$heading-small-font-size-m: $heading-medium-font-size-l * 0.8125 !default; -$heading-small-font-size: $heading-small-font-size-m * 0.8 !default; -$heading-medium-font-size-m: $heading-medium-font-size-l * 0.875 !default; -$heading-medium-font-size: $heading-medium-font-size-m * 0.825 !default; -$heading-large-font-size-m: $heading-medium-font-size-l !default; -$heading-large-font-size: $heading-large-font-size-m * 0.85 !default; -$heading-xlarge-font-size: $heading-large-font-size-m !default; -$heading-large-font-size-l: 6rem !default; -$heading-xlarge-font-size-m: $heading-large-font-size-l !default; -$heading-2xlarge-font-size: $heading-xlarge-font-size-m !default; -$heading-xlarge-font-size-l: 8rem !default; -$heading-2xlarge-font-size-m: $heading-xlarge-font-size-l !default; -$heading-2xlarge-font-size-l: 11rem !default; -$heading-small-line-height: 1.2 !default; -$heading-medium-line-height: 1.1 !default; -$heading-large-line-height: 1.1 !default; -$heading-xlarge-line-height: 1 !default; -$heading-2xlarge-line-height: 1 !default; -$heading-divider-padding-bottom: unquote('calc(5px + 0.1em)') !default; -$heading-divider-border-width: unquote('calc(0.2px + 0.05em)') !default; -$heading-divider-border: $global-border !default; -$heading-bullet-top: unquote('calc(-0.1 * 1em)') !default; -$heading-bullet-height: unquote('calc(4px + 0.7em)') !default; -$heading-bullet-margin-right: unquote('calc(5px + 0.2em)') !default; -$heading-bullet-border-width: unquote('calc(5px + 0.1em)') !default; -$heading-bullet-border: $global-border !default; -$heading-line-top: 50% !default; -$heading-line-border-width: unquote('calc(0.2px + 0.05em)') !default; -$heading-line-height: $heading-line-border-width !default; -$heading-line-width: 2000px !default; -$heading-line-border: $global-border !default; -$heading-line-margin-horizontal: unquote('calc(5px + 0.3em)') !default; -$heading-primary-font-size-l: 3.75rem !default; -$heading-primary-line-height-l: 1.1 !default; -$heading-primary-font-size-m: $heading-primary-font-size-l * 0.9 !default; -$heading-primary-font-size: $heading-primary-font-size-l * 0.8 !default; -$heading-primary-line-height: 1.2 !default; -$heading-hero-font-size-l: 8rem !default; -$heading-hero-line-height-l: 1 !default; -$heading-hero-font-size-m: $heading-hero-font-size-l * 0.75 !default; -$heading-hero-line-height-m: 1 !default; -$heading-hero-font-size: $heading-hero-font-size-l * 0.5 !default; -$heading-hero-line-height: 1.1 !default; -$inverse-heading-divider-border: $inverse-global-border !default; -$inverse-heading-bullet-border: $inverse-global-border !default; -$inverse-heading-line-border: $inverse-global-border !default; -$height-small-height: 150px !default; -$height-medium-height: 300px !default; -$height-large-height: 450px !default; -$icon-image-size: 20px !default; -$icon-link-color: $global-muted-color !default; -$icon-link-hover-color: $global-color !default; -$icon-link-active-color: darken($global-color, 5%) !default; -$icon-button-size: 36px !default; -$icon-button-border-radius: 500px !default; -$icon-button-background: $global-muted-background !default; -$icon-button-color: $global-muted-color !default; -$icon-button-hover-background: darken($icon-button-background, 5%) !default; -$icon-button-hover-color: $global-color !default; -$icon-button-active-background: darken($icon-button-background, 10%) !default; -$icon-button-active-color: $global-color !default; -$inverse-icon-link-color: $inverse-global-muted-color !default; -$inverse-icon-link-hover-color: $inverse-global-color !default; -$inverse-icon-link-active-color: $inverse-global-color !default; -$inverse-icon-button-background: $inverse-global-muted-background !default; -$inverse-icon-button-color: $inverse-global-muted-color !default; -$inverse-icon-button-hover-background: fadein($inverse-icon-button-background, 5%) !default; -$inverse-icon-button-hover-color: $inverse-global-color !default; -$inverse-icon-button-active-background: fadein($inverse-icon-button-background, 10%) !default; -$inverse-icon-button-active-color: $inverse-global-color !default; -$iconnav-margin-horizontal: $global-small-margin !default; -$iconnav-margin-vertical: $iconnav-margin-horizontal !default; -$iconnav-item-color: $global-muted-color !default; -$iconnav-item-hover-color: $global-color !default; -$iconnav-item-active-color: $global-color !default; -$inverse-iconnav-item-color: $inverse-global-muted-color !default; -$inverse-iconnav-item-hover-color: $inverse-global-color !default; -$inverse-iconnav-item-active-color: $inverse-global-color !default; -$inverse-global-color-mode: light !default; -$label-padding-vertical: 0 !default; -$label-padding-horizontal: $global-small-margin !default; -$label-background: $global-primary-background !default; -$label-line-height: $global-line-height !default; -$label-font-size: $global-small-font-size !default; -$label-color: $global-inverse-color !default; -$label-success-background: $global-success-background !default; -$label-success-color: $global-inverse-color !default; -$label-warning-background: $global-warning-background !default; -$label-warning-color: $global-inverse-color !default; -$label-danger-background: $global-danger-background !default; -$label-danger-color: $global-inverse-color !default; -$inverse-label-background: $inverse-global-primary-background !default; -$inverse-label-color: $inverse-global-inverse-color !default; -$leader-fill-content: unquote('.') !default; -$leader-fill-margin-left: $global-small-gutter !default; -$lightbox-z-index: $global-z-index + 10 !default; -$lightbox-background: #000 !default; -$lightbox-item-color: rgba(255,255,255,0.7) !default; -$lightbox-item-max-width: 100vw !default; -$lightbox-item-max-height: 100vh !default; -$lightbox-toolbar-padding-vertical: 10px !default; -$lightbox-toolbar-padding-horizontal: 10px !default; -$lightbox-toolbar-background: rgba(0,0,0,0.3) !default; -$lightbox-toolbar-color: rgba(255,255,255,0.7) !default; -$lightbox-toolbar-icon-padding: 5px !default; -$lightbox-toolbar-icon-color: rgba(255,255,255,0.7) !default; -$lightbox-toolbar-icon-hover-color: #fff !default; -$lightbox-button-size: 50px !default; -$lightbox-button-background: $lightbox-toolbar-background !default; -$lightbox-button-color: rgba(255,255,255,0.7) !default; -$lightbox-button-hover-color: #fff !default; -$link-muted-color: $global-muted-color !default; -$link-muted-hover-color: $global-color !default; -$link-text-hover-color: $global-muted-color !default; -$link-heading-hover-color: $global-primary-background !default; -$link-heading-hover-text-decoration: none !default; -$inverse-link-muted-color: $inverse-global-muted-color !default; -$inverse-link-muted-hover-color: $inverse-global-color !default; -$inverse-link-text-hover-color: $inverse-global-muted-color !default; -$inverse-link-heading-hover-color: $inverse-global-primary-background !default; -$list-margin-top: $global-small-margin !default; -$list-padding-left: 30px !default; -$list-marker-height: ($global-line-height * 1em) !default; -$list-muted-color: $global-muted-color !default; -$list-emphasis-color: $global-emphasis-color !default; -$list-primary-color: $global-primary-background !default; -$list-secondary-color: $global-secondary-background !default; -$list-bullet-icon-color: $global-color !default; -$list-divider-margin-top: $global-small-margin !default; -$list-divider-border-width: $global-border-width !default; -$list-divider-border: $global-border !default; -$list-striped-padding-vertical: $global-small-margin !default; -$list-striped-padding-horizontal: $global-small-margin !default; -$list-striped-background: $global-muted-background !default; -$list-large-margin-top: $global-margin !default; -$list-large-divider-margin-top: $global-margin !default; -$list-large-striped-padding-vertical: $global-margin !default; -$list-large-striped-padding-horizontal: $global-small-margin !default; -$internal-list-bullet-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%226%22%20height%3D%226%22%20viewBox%3D%220%200%206%206%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Ccircle%20fill%3D%22#000%22%20cx%3D%223%22%20cy%3D%223%22%20r%3D%223%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$inverse-list-muted-color: $inverse-global-muted-color !default; -$inverse-list-emphasis-color: $inverse-global-emphasis-color !default; -$inverse-list-primary-color: $inverse-global-primary-background !default; -$inverse-list-secondary-color: $inverse-global-primary-background !default; -$inverse-list-divider-border: $inverse-global-border !default; -$inverse-list-striped-background: $inverse-global-muted-background !default; -$inverse-list-bullet-icon-color: $inverse-global-color !default; -$margin-margin: $global-margin !default; -$margin-small-margin: $global-small-margin !default; -$margin-medium-margin: $global-medium-margin !default; -$margin-large-margin: $global-medium-margin !default; -$margin-large-margin-l: $global-large-margin !default; -$margin-xlarge-margin: $global-large-margin !default; -$global-xlarge-margin: 140px !default; -$margin-xlarge-margin-l: $global-xlarge-margin !default; -$marker-padding: 5px !default; -$marker-background: $global-secondary-background !default; -$marker-color: $global-inverse-color !default; -$marker-hover-color: $global-inverse-color !default; -$inverse-marker-background: $global-muted-background !default; -$inverse-marker-color: $global-color !default; -$inverse-marker-hover-color: $global-color !default; -$modal-z-index: $global-z-index + 10 !default; -$modal-background: rgba(0,0,0,0.6) !default; -$modal-padding-horizontal: 15px !default; -$modal-padding-horizontal-s: $global-gutter !default; -$modal-padding-horizontal-m: $global-medium-gutter !default; -$modal-padding-vertical: $modal-padding-horizontal !default; -$modal-padding-vertical-s: 50px !default; -$modal-dialog-width: 600px !default; -$modal-dialog-background: $global-background !default; -$modal-container-width: 1200px !default; -$modal-body-padding-horizontal: $global-gutter !default; -$modal-body-padding-vertical: $global-gutter !default; -$modal-header-padding-horizontal: $global-gutter !default; -$modal-header-padding-vertical: ($modal-header-padding-horizontal / 2) !default; -$modal-header-background: $global-muted-background !default; -$modal-footer-padding-horizontal: $global-gutter !default; -$modal-footer-padding-vertical: ($modal-footer-padding-horizontal / 2) !default; -$modal-footer-background: $global-muted-background !default; -$modal-title-font-size: $global-xlarge-font-size !default; -$modal-title-line-height: 1.3 !default; -$modal-close-position: $global-small-margin !default; -$modal-close-padding: 5px !default; -$modal-close-outside-position: 0 !default; -$modal-close-outside-translate: 100% !default; -$modal-close-outside-color: lighten($global-inverse-color, 20%) !default; -$modal-close-outside-hover-color: $global-inverse-color !default; -$nav-item-padding-vertical: 5px !default; -$nav-item-padding-horizontal: 0 !default; -$nav-sublist-padding-vertical: 5px !default; -$nav-sublist-padding-left: 15px !default; -$nav-sublist-deeper-padding-left: 15px !default; -$nav-sublist-item-padding-vertical: 2px !default; -$nav-parent-icon-width: ($global-line-height * 1em) !default; -$nav-parent-icon-height: $nav-parent-icon-width !default; -$nav-parent-icon-color: $global-color !default; -$nav-header-padding-vertical: $nav-item-padding-vertical !default; -$nav-header-padding-horizontal: $nav-item-padding-horizontal !default; -$nav-header-font-size: $global-small-font-size !default; -$nav-header-text-transform: uppercase !default; -$nav-header-margin-top: $global-margin !default; -$nav-divider-margin-vertical: 5px !default; -$nav-divider-margin-horizontal: 0 !default; -$nav-default-item-color: $global-muted-color !default; -$nav-default-item-hover-color: $global-color !default; -$nav-default-item-active-color: $global-emphasis-color !default; -$nav-default-header-color: $global-emphasis-color !default; -$nav-default-divider-border-width: $global-border-width !default; -$nav-default-divider-border: $global-border !default; -$nav-default-sublist-item-color: $global-muted-color !default; -$nav-default-sublist-item-hover-color: $global-color !default; -$nav-default-sublist-item-active-color: $global-emphasis-color !default; -$nav-primary-item-font-size: $global-large-font-size !default; -$nav-primary-item-line-height: $global-line-height !default; -$nav-primary-item-color: $global-muted-color !default; -$nav-primary-item-hover-color: $global-color !default; -$nav-primary-item-active-color: $global-emphasis-color !default; -$nav-primary-header-color: $global-emphasis-color !default; -$nav-primary-divider-border-width: $global-border-width !default; -$nav-primary-divider-border: $global-border !default; -$nav-primary-sublist-item-color: $global-muted-color !default; -$nav-primary-sublist-item-hover-color: $global-color !default; -$nav-primary-sublist-item-active-color: $global-emphasis-color !default; -$nav-dividers-margin-top: 0 !default; -$nav-dividers-border-width: $global-border-width !default; -$nav-dividers-border: $global-border !default; -$internal-nav-parent-close-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolyline%20fill%3D%22none%22%20stroke%3D%22#000%22%20stroke-width%3D%221.1%22%20points%3D%2210%201%204%207%2010%2013%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$internal-nav-parent-open-image: "data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2214%22%20height%3D%2214%22%20viewBox%3D%220%200%2014%2014%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%20%20%20%20%3Cpolyline%20fill%3D%22none%22%20stroke%3D%22#000%22%20stroke-width%3D%221.1%22%20points%3D%221%204%207%2010%2013%204%22%20%2F%3E%0A%3C%2Fsvg%3E" !default; -$inverse-nav-parent-icon-color: $inverse-global-color !default; -$inverse-nav-default-item-color: $inverse-global-muted-color !default; -$inverse-nav-default-item-hover-color: $inverse-global-color !default; -$inverse-nav-default-item-active-color: $inverse-global-emphasis-color !default; -$inverse-nav-default-header-color: $inverse-global-emphasis-color !default; -$inverse-nav-default-divider-border: $inverse-global-border !default; -$inverse-nav-default-sublist-item-color: $inverse-global-muted-color !default; -$inverse-nav-default-sublist-item-hover-color: $inverse-global-color !default; -$inverse-nav-default-sublist-item-active-color: $inverse-global-emphasis-color !default; -$inverse-nav-primary-item-color: $inverse-global-muted-color !default; -$inverse-nav-primary-item-hover-color: $inverse-global-color !default; -$inverse-nav-primary-item-active-color: $inverse-global-emphasis-color !default; -$inverse-nav-primary-header-color: $inverse-global-emphasis-color !default; -$inverse-nav-primary-divider-border: $inverse-global-border !default; -$inverse-nav-primary-sublist-item-color: $inverse-global-muted-color !default; -$inverse-nav-primary-sublist-item-hover-color: $inverse-global-color !default; -$inverse-nav-primary-sublist-item-active-color: $inverse-global-emphasis-color !default; -$inverse-nav-dividers-border: $inverse-global-border !default; -$navbar-background: $global-muted-background !default; -$navbar-color-mode: none !default; -$navbar-nav-item-height: 80px !default; -$navbar-nav-item-padding-horizontal: 15px !default; -$navbar-nav-item-color: $global-muted-color !default; -$navbar-nav-item-font-size: $global-font-size !default; -$navbar-nav-item-font-family: $global-font-family !default; -$navbar-nav-item-hover-color: $global-color !default; -$navbar-nav-item-onclick-color: $global-emphasis-color !default; -$navbar-nav-item-active-color: $global-emphasis-color !default; -$navbar-item-color: $global-color !default; -$navbar-toggle-color: $global-muted-color !default; -$navbar-toggle-hover-color: $global-color !default; -$navbar-subtitle-font-size: $global-small-font-size !default; -$navbar-dropdown-z-index: $global-z-index + 20 !default; -$navbar-dropdown-width: 200px !default; -$navbar-dropdown-margin: 0 !default; -$navbar-dropdown-padding: 15px !default; -$navbar-dropdown-background: $global-muted-background !default; -$navbar-dropdown-color: $global-color !default; -$navbar-dropdown-grid-gutter-horizontal: $global-gutter !default; -$navbar-dropdown-grid-gutter-vertical: $navbar-dropdown-grid-gutter-horizontal !default; -$navbar-dropdown-dropbar-margin-top: 0 !default; -$navbar-dropdown-dropbar-margin-bottom: $navbar-dropdown-dropbar-margin-top !default; -$navbar-dropdown-nav-item-color: $global-muted-color !default; -$navbar-dropdown-nav-item-hover-color: $global-color !default; -$navbar-dropdown-nav-item-active-color: $global-emphasis-color !default; -$navbar-dropdown-nav-header-color: $global-emphasis-color !default; -$navbar-dropdown-nav-divider-border-width: $global-border-width !default; -$navbar-dropdown-nav-divider-border: $global-border !default; -$navbar-dropdown-nav-sublist-item-color: $global-muted-color !default; -$navbar-dropdown-nav-sublist-item-hover-color: $global-color !default; -$navbar-dropdown-nav-sublist-item-active-color: $global-emphasis-color !default; -$navbar-dropbar-background: $navbar-dropdown-background !default; -$navbar-dropbar-z-index: $global-z-index - 20 !default; -$inverse-navbar-nav-item-color: $inverse-global-muted-color !default; -$inverse-navbar-nav-item-hover-color: $inverse-global-color !default; -$inverse-navbar-nav-item-onclick-color: $inverse-global-emphasis-color !default; -$inverse-navbar-nav-item-active-color: $inverse-global-emphasis-color !default; -$inverse-navbar-item-color: $inverse-global-color !default; -$inverse-navbar-toggle-color: $inverse-global-muted-color !default; -$inverse-navbar-toggle-hover-color: $inverse-global-color !default; -$notification-position: 10px !default; -$notification-z-index: $global-z-index + 40 !default; -$notification-width: 350px !default; -$notification-message-margin-top: 10px !default; -$notification-message-padding: $global-small-gutter !default; -$notification-message-background: $global-muted-background !default; -$notification-message-color: $global-color !default; -$notification-message-font-size: $global-medium-font-size !default; -$notification-message-line-height: 1.4 !default; -$notification-close-top: $notification-message-padding + 5px !default; -$notification-close-right: $notification-message-padding !default; -$notification-message-primary-color: $global-primary-background !default; -$notification-message-success-color: $global-success-background !default; -$notification-message-warning-color: $global-warning-background !default; -$notification-message-danger-color: $global-danger-background !default; -$offcanvas-z-index: $global-z-index !default; -$offcanvas-bar-width: 270px !default; -$offcanvas-bar-padding-vertical: $global-margin !default; -$offcanvas-bar-padding-horizontal: $global-margin !default; -$offcanvas-bar-background: $global-secondary-background !default; -$offcanvas-bar-color-mode: light !default; -$offcanvas-bar-width-m: 350px !default; -$offcanvas-bar-padding-vertical-m: $global-medium-gutter !default; -$offcanvas-bar-padding-horizontal-m: $global-medium-gutter !default; -$offcanvas-close-position: 20px !default; -$offcanvas-close-padding: 5px !default; -$offcanvas-overlay-background: rgba(0,0,0,0.1) !default; -$overlay-padding-horizontal: $global-gutter !default; -$overlay-padding-vertical: $global-gutter !default; -$overlay-default-background: rgba($global-background, 0.8) !default; -$overlay-primary-background: rgba($global-secondary-background, 0.8) !default; -$overlay-primary-color-mode: light !default; -$padding-padding: $global-gutter !default; -$padding-padding-l: $global-medium-gutter !default; -$padding-small-padding: $global-small-gutter !default; -$padding-large-padding: $global-gutter !default; -$padding-large-padding-l: $global-large-gutter !default; -$pagination-margin-horizontal: 0 !default; -$pagination-item-padding-vertical: 5px !default; -$pagination-item-padding-horizontal: 10px !default; -$pagination-item-color: $global-muted-color !default; -$pagination-item-hover-color: $global-color !default; -$pagination-item-hover-text-decoration: none !default; -$pagination-item-active-color: $global-color !default; -$pagination-item-disabled-color: $global-muted-color !default; -$inverse-pagination-item-color: $inverse-global-muted-color !default; -$inverse-pagination-item-hover-color: $inverse-global-color !default; -$inverse-pagination-item-active-color: $inverse-global-color !default; -$inverse-pagination-item-disabled-color: $inverse-global-muted-color !default; -$placeholder-margin-vertical: $global-margin !default; -$placeholder-padding-vertical: $global-gutter !default; -$placeholder-padding-horizontal: $global-gutter !default; -$placeholder-background: $global-muted-background !default; -$position-small-margin: $global-small-gutter !default; -$position-medium-margin: $global-gutter !default; -$position-large-margin: $global-gutter !default; -$position-large-margin-l: 50px !default; -$progress-height: 15px !default; -$progress-margin-vertical: $global-margin !default; -$progress-background: $global-muted-background !default; -$progress-bar-background: $global-primary-background !default; -$search-color: $global-color !default; -$search-placeholder-color: $global-muted-color !default; -$search-icon-color: $global-muted-color !default; -$search-default-width: 240px !default; -$search-default-height: $global-control-height !default; -$search-default-padding-horizontal: 10px !default; -$search-default-background: $global-muted-background !default; -$search-default-focus-background: darken($search-default-background, 5%) !default; -$search-default-icon-width: $global-control-height !default; -$search-navbar-width: 400px !default; -$search-navbar-height: 40px !default; -$search-navbar-background: transparent !default; -$search-navbar-font-size: $global-large-font-size !default; -$search-navbar-icon-width: 40px !default; -$search-large-width: 500px !default; -$search-large-height: 80px !default; -$search-large-background: transparent !default; -$search-large-font-size: $global-2xlarge-font-size !default; -$search-large-icon-width: 80px !default; -$search-toggle-color: $global-muted-color !default; -$search-toggle-hover-color: $global-color !default; -$inverse-search-color: $inverse-global-color !default; -$inverse-search-placeholder-color: $inverse-global-muted-color !default; -$inverse-search-icon-color: $inverse-global-muted-color !default; -$inverse-search-default-background: $inverse-global-muted-background !default; -$inverse-search-default-focus-background: fadein($inverse-search-default-background, 5%) !default; -$inverse-search-navbar-background: transparent !default; -$inverse-search-large-background: transparent !default; -$inverse-search-toggle-color: $inverse-global-muted-color !default; -$inverse-search-toggle-hover-color: $inverse-global-color !default; -$section-padding-vertical: $global-medium-margin !default; -$section-padding-vertical-m: $global-large-margin !default; -$section-xsmall-padding-vertical: $global-margin !default; -$section-small-padding-vertical: $global-medium-margin !default; -$section-large-padding-vertical: $global-large-margin !default; -$section-large-padding-vertical-m: $global-xlarge-margin !default; -$section-xlarge-padding-vertical: $global-xlarge-margin !default; -$section-xlarge-padding-vertical-m: ($global-large-margin + $global-xlarge-margin) !default; -$section-default-background: $global-background !default; -$section-muted-background: $global-muted-background !default; -$section-primary-background: $global-primary-background !default; -$section-primary-color-mode: light !default; -$section-secondary-background: $global-secondary-background !default; -$section-secondary-color-mode: light !default; -$slidenav-padding-vertical: 5px !default; -$slidenav-padding-horizontal: 10px !default; -$slidenav-color: rgba($global-color, 0.5) !default; -$slidenav-hover-color: rgba($global-color, 0.9) !default; -$slidenav-active-color: rgba($global-color, 0.5) !default; -$slidenav-large-padding-vertical: 10px !default; -$slidenav-large-padding-horizontal: $slidenav-large-padding-vertical !default; -$inverse-slidenav-color: rgba($inverse-global-color, 0.7) !default; -$inverse-slidenav-hover-color: rgba($inverse-global-color, 0.95) !default; -$inverse-slidenav-active-color: rgba($inverse-global-color, 0.7) !default; -$slider-container-margin-top: -11px !default; -$slider-container-margin-bottom: -39px !default; -$slider-container-margin-left: -25px !default; -$slider-container-margin-right: -25px !default; -$sortable-dragged-z-index: $global-z-index + 50 !default; -$sortable-placeholder-opacity: 0 !default; -$sortable-empty-height: 50px !default; -$spinner-size: 30px !default; -$spinner-stroke-width: 1 !default; -$spinner-radius: floor(($spinner-size - $spinner-stroke-width) / 2) !default; -$spinner-circumference: round(2 * 3.141 * $spinner-radius) !default; -$spinner-duration: 1.4s !default; -$sticky-z-index: $global-z-index - 20 !default; -$sticky-animation-duration: 0.2s !default; -$sticky-reverse-animation-duration: 0.2s !default; -$subnav-margin-horizontal: 20px !default; -$subnav-item-color: $global-muted-color !default; -$subnav-item-hover-color: $global-color !default; -$subnav-item-hover-text-decoration: none !default; -$subnav-item-active-color: $global-emphasis-color !default; -$subnav-divider-margin-horizontal: $subnav-margin-horizontal !default; -$subnav-divider-border-height: 1.5em !default; -$subnav-divider-border-width: $global-border-width !default; -$subnav-divider-border: $global-border !default; -$subnav-pill-item-padding-vertical: 5px !default; -$subnav-pill-item-padding-horizontal: 10px !default; -$subnav-pill-item-background: transparent !default; -$subnav-pill-item-color: $subnav-item-color !default; -$subnav-pill-item-hover-background: $global-muted-background !default; -$subnav-pill-item-hover-color: $global-color !default; -$subnav-pill-item-onclick-background: $subnav-pill-item-hover-background !default; -$subnav-pill-item-onclick-color: $subnav-pill-item-hover-color !default; -$subnav-pill-item-active-background: $global-primary-background !default; -$subnav-pill-item-active-color: $global-inverse-color !default; -$subnav-item-disabled-color: $global-muted-color !default; -$inverse-subnav-item-color: $inverse-global-muted-color !default; -$inverse-subnav-item-hover-color: $inverse-global-color !default; -$inverse-subnav-item-active-color: $inverse-global-emphasis-color !default; -$inverse-subnav-divider-border: $inverse-global-border !default; -$inverse-subnav-pill-item-background: transparent !default; -$inverse-subnav-pill-item-color: $inverse-global-muted-color !default; -$inverse-subnav-pill-item-hover-background: $inverse-global-muted-background !default; -$inverse-subnav-pill-item-hover-color: $inverse-global-color !default; -$inverse-subnav-pill-item-onclick-background: $inverse-subnav-pill-item-hover-background !default; -$inverse-subnav-pill-item-onclick-color: $inverse-subnav-pill-item-hover-color !default; -$inverse-subnav-pill-item-active-background: $inverse-global-primary-background !default; -$inverse-subnav-pill-item-active-color: $inverse-global-inverse-color !default; -$inverse-subnav-item-disabled-color: $inverse-global-muted-color !default; -$tab-margin-horizontal: 20px !default; -$tab-item-padding-horizontal: 10px !default; -$tab-item-padding-vertical: 5px !default; -$tab-item-color: $global-muted-color !default; -$tab-item-hover-color: $global-color !default; -$tab-item-hover-text-decoration: none !default; -$tab-item-active-color: $global-emphasis-color !default; -$tab-item-disabled-color: $global-muted-color !default; -$inverse-tab-item-color: $inverse-global-muted-color !default; -$inverse-tab-item-hover-color: $inverse-global-color !default; -$inverse-tab-item-active-color: $inverse-global-emphasis-color !default; -$inverse-tab-item-disabled-color: $inverse-global-muted-color !default; -$table-margin-vertical: $global-margin !default; -$table-cell-padding-vertical: 16px !default; -$table-cell-padding-horizontal: 12px !default; -$table-header-cell-font-size: $global-font-size !default; -$table-header-cell-font-weight: bold !default; -$table-header-cell-color: $global-color !default; -$table-footer-font-size: $global-small-font-size !default; -$table-caption-font-size: $global-small-font-size !default; -$table-caption-color: $global-muted-color !default; -$table-row-active-background: #ffd !default; -$table-divider-border-width: $global-border-width !default; -$table-divider-border: $global-border !default; -$table-striped-row-background: $global-muted-background !default; -$table-hover-row-background: $table-row-active-background !default; -$table-small-cell-padding-vertical: 10px !default; -$table-small-cell-padding-horizontal: 12px !default; -$table-large-cell-padding-vertical: 22px !default; -$table-large-cell-padding-horizontal: 12px !default; -$table-expand-min-width: 150px !default; -$inverse-table-header-cell-color: $inverse-global-color !default; -$inverse-table-caption-color: $inverse-global-muted-color !default; -$inverse-table-row-active-background: fade-out($inverse-global-muted-background, 0.02) !default; -$inverse-table-divider-border: $inverse-global-border !default; -$inverse-table-striped-row-background: $inverse-global-muted-background !default; -$inverse-table-hover-row-background: $inverse-table-row-active-background !default; -$text-lead-font-size: $global-large-font-size !default; -$text-lead-line-height: 1.5 !default; -$text-lead-color: $global-emphasis-color !default; -$text-meta-font-size: $global-small-font-size !default; -$text-meta-line-height: 1.4 !default; -$text-meta-color: $global-muted-color !default; -$text-small-font-size: $global-small-font-size !default; -$text-small-line-height: 1.5 !default; -$text-large-font-size: $global-large-font-size !default; -$text-large-line-height: 1.5 !default; -$text-muted-color: $global-muted-color !default; -$text-emphasis-color: $global-emphasis-color !default; -$text-primary-color: $global-primary-background !default; -$text-secondary-color: $global-secondary-background !default; -$text-success-color: $global-success-background !default; -$text-warning-color: $global-warning-background !default; -$text-danger-color: $global-danger-background !default; -$text-background-color: $global-primary-background !default; -$inverse-text-lead-color: $inverse-global-color !default; -$inverse-text-meta-color: $inverse-global-muted-color !default; -$inverse-text-muted-color: $inverse-global-muted-color !default; -$inverse-text-emphasis-color: $inverse-global-emphasis-color !default; -$inverse-text-primary-color: $inverse-global-primary-background !default; -$inverse-text-secondary-color: $inverse-global-primary-background !default; -$thumbnav-margin-horizontal: 15px !default; -$thumbnav-margin-vertical: $thumbnav-margin-horizontal !default; -$tile-padding-horizontal: 15px !default; -$tile-padding-horizontal-s: $global-gutter !default; -$tile-padding-horizontal-m: $global-medium-gutter !default; -$tile-padding-vertical: $global-medium-margin !default; -$tile-padding-vertical-m: $global-large-margin !default; -$tile-xsmall-padding-vertical: $global-margin !default; -$tile-small-padding-vertical: $global-medium-margin !default; -$tile-large-padding-vertical: $global-large-margin !default; -$tile-large-padding-vertical-m: $global-xlarge-margin !default; -$tile-xlarge-padding-vertical: $global-xlarge-margin !default; -$tile-xlarge-padding-vertical-m: ($global-large-margin + $global-xlarge-margin) !default; -$tile-default-background: $global-background !default; -$tile-muted-background: $global-muted-background !default; -$tile-primary-background: $global-primary-background !default; -$tile-primary-color-mode: light !default; -$tile-secondary-background: $global-secondary-background !default; -$tile-secondary-color-mode: light !default; -$tooltip-z-index: $global-z-index + 30 !default; -$tooltip-max-width: 200px !default; -$tooltip-padding-vertical: 3px !default; -$tooltip-padding-horizontal: 6px !default; -$tooltip-background: #666 !default; -$tooltip-border-radius: 2px !default; -$tooltip-color: $global-inverse-color !default; -$tooltip-font-size: 12px !default; -$tooltip-margin: 10px !default; -$totop-padding: 5px !default; -$totop-color: $global-muted-color !default; -$totop-hover-color: $global-color !default; -$totop-active-color: $global-emphasis-color !default; -$inverse-totop-color: $inverse-global-muted-color !default; -$inverse-totop-hover-color: $inverse-global-color !default; -$inverse-totop-active-color: $inverse-global-emphasis-color !default; -$transition-duration: 0.3s !default; -$transition-scale: 1.03 !default; -$transition-slide-small-translate: 10px !default; -$transition-slide-medium-translate: 50px !default; -$transition-slow-duration: 0.7s !default; -$panel-scrollable-height: 170px !default; -$panel-scrollable-padding: 10px !default; -$panel-scrollable-border-width: $global-border-width !default; -$panel-scrollable-border: $global-border !default; -$border-rounded-border-radius: 5px !default; -$box-shadow-duration: 0.1s !default; -$box-shadow-bottom-height: 30px !default; -$box-shadow-bottom-border-radius: 100% !default; -$box-shadow-bottom-background: #444 !default; -$box-shadow-bottom-blur: 20px !default; -$dropcap-margin-right: 10px !default; -$dropcap-font-size: (($global-line-height * 3) * 1em) !default; -$logo-font-size: $global-large-font-size !default; -$logo-font-family: $global-font-family !default; -$logo-color: $global-color !default; -$logo-hover-color: $global-color !default; -$dragover-box-shadow: 0 0 20px rgba(100,100,100,0.3) !default; -$inverse-logo-color: $inverse-global-color !default; -$inverse-logo-hover-color: $inverse-global-color !default; -$deprecated: false !default; -$breakpoint-small: 640px !default; -$breakpoint-medium: 960px !default; -$breakpoint-large: 1200px !default; -$breakpoint-xlarge: 1600px !default; -$breakpoint-xsmall-max: ($breakpoint-small - 1) !default; -$breakpoint-small-max: ($breakpoint-medium - 1) !default; -$breakpoint-medium-max: ($breakpoint-large - 1) !default; -$breakpoint-large-max: ($breakpoint-xlarge - 1) !default; -$global-small-box-shadow: 0 2px 8px rgba(0,0,0,0.08) !default; -$global-medium-box-shadow: 0 5px 15px rgba(0,0,0,0.08) !default; -$global-large-box-shadow: 0 14px 25px rgba(0,0,0,0.16) !default; -$global-xlarge-box-shadow: 0 28px 50px rgba(0,0,0,0.16) !default; -$width-small-width: 150px !default; -$width-medium-width: 300px !default; -$width-large-width: 450px !default; -$width-xlarge-width: 600px !default; -$width-2xlarge-width: 750px !default; \ No newline at end of file diff --git a/docs/assets/css/prism.css b/docs/assets/css/prism.css deleted file mode 100644 index 5465ca64b9..0000000000 --- a/docs/assets/css/prism.css +++ /dev/null @@ -1,218 +0,0 @@ -/* PrismJS 1.24.1 -https://prismjs.com/download.html#themes=prism-solarizedlight&languages=markup+clike+bash+git+groovy+handlebars+java+javadoc+javadoclike+javastacktrace+json+kotlin+markup-templating+nginx+shell-session+xml-doc+yaml&plugins=normalize-whitespace+toolbar+copy-to-clipboard */ -/* - Solarized Color Schemes originally by Ethan Schoonover - http://ethanschoonover.com/solarized - - Ported for PrismJS by Hector Matos - Website: https://krakendev.io - Twitter Handle: https://twitter.com/allonsykraken) -*/ - -/* -SOLARIZED HEX ---------- ------- -base03 #002b36 -base02 #073642 -base01 #586e75 -base00 #657b83 -base0 #839496 -base1 #93a1a1 -base2 #eee8d5 -base3 #FEF6EB -yellow #b58900 -orange #cb4b16 -red #dc322f -magenta #d33682 -violet #6c71c4 -blue #268bd2 -cyan #2aa198 -green #859900 -*/ - -code[class*="language-"], -pre[class*="language-"] { - color: #657b83; /* base00 */ - font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; - font-size: 1em; - text-align: left; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; - - line-height: 1.5; - - -moz-tab-size: 4; - -o-tab-size: 4; - tab-size: 4; - - -webkit-hyphens: none; - -moz-hyphens: none; - -ms-hyphens: none; - hyphens: none; -} - -pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, -code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { - background: #073642; /* base02 */ -} - -pre[class*="language-"]::selection, pre[class*="language-"] ::selection, -code[class*="language-"]::selection, code[class*="language-"] ::selection { - background: #073642; /* base02 */ -} - -/* Code blocks */ -pre[class*="language-"] { - padding: 1em; - margin: .5em 0; - overflow: auto; - border-radius: 0.3em; -} - -:not(pre) > code[class*="language-"], -pre[class*="language-"] { - background-color: #FEF6EB; /* base3 */ -} - -/* Inline code */ -:not(pre) > code[class*="language-"] { - padding: .1em; - border-radius: .3em; -} - -.token.comment, -.token.prolog, -.token.doctype, -.token.cdata { - color: #93a1a1; /* base1 */ -} - -.token.punctuation { - color: #586e75; /* base01 */ -} - -.token.namespace { - opacity: .7; -} - -.token.property, -.token.tag, -.token.boolean, -.token.number, -.token.constant, -.token.symbol, -.token.deleted { - color: #268bd2; /* blue */ -} - -.token.selector, -.token.attr-name, -.token.string, -.token.char, -.token.builtin, -.token.url, -.token.inserted { - color: #2aa198; /* cyan */ -} - -.token.entity { - color: #657b83; /* base00 */ - background: #eee8d5; /* base2 */ -} - -.token.atrule, -.token.attr-value, -.token.keyword { - color: #859900; /* green */ -} - -.token.function, -.token.class-name { - color: #b58900; /* yellow */ -} - -.token.regex, -.token.important, -.token.variable { - color: #cb4b16; /* orange */ -} - -.token.important, -.token.bold { - font-weight: bold; -} -.token.italic { - font-style: italic; -} - -.token.entity { - cursor: help; -} - -div.code-toolbar { - position: relative; -} - -div.code-toolbar > .toolbar { - position: absolute; - top: .3em; - right: .2em; - transition: opacity 0.3s ease-in-out; - opacity: 0; -} - -div.code-toolbar:hover > .toolbar { - opacity: 1; -} - -/* Separate line b/c rules are thrown out if selector is invalid. - IE11 and old Edge versions don't support :focus-within. */ -div.code-toolbar:focus-within > .toolbar { - opacity: 1; -} - -div.code-toolbar > .toolbar > .toolbar-item { - display: inline-block; -} - -div.code-toolbar > .toolbar > .toolbar-item > a { - cursor: pointer; -} - -div.code-toolbar > .toolbar > .toolbar-item > button { - background: none; - border: 0; - color: inherit; - font: inherit; - line-height: normal; - overflow: visible; - padding: 0; - -webkit-user-select: none; /* for button */ - -moz-user-select: none; - -ms-user-select: none; -} - -div.code-toolbar > .toolbar > .toolbar-item > a, -div.code-toolbar > .toolbar > .toolbar-item > button, -div.code-toolbar > .toolbar > .toolbar-item > span { - color: #bbb; - font-size: .8em; - padding: 0 .5em; - background: #f5f2f0; - background: rgba(224, 224, 224, 0.2); - box-shadow: 0 2px 0 0 rgba(0,0,0,0.2); - border-radius: .5em; -} - -div.code-toolbar > .toolbar > .toolbar-item > a:hover, -div.code-toolbar > .toolbar > .toolbar-item > a:focus, -div.code-toolbar > .toolbar > .toolbar-item > button:hover, -div.code-toolbar > .toolbar > .toolbar-item > button:focus, -div.code-toolbar > .toolbar > .toolbar-item > span:hover, -div.code-toolbar > .toolbar > .toolbar-item > span:focus { - color: inherit; - text-decoration: none; -} - diff --git a/docs/assets/css/style.scss b/docs/assets/css/style.scss deleted file mode 100644 index 3930cb46ac..0000000000 --- a/docs/assets/css/style.scss +++ /dev/null @@ -1,18 +0,0 @@ ---- -# this ensures Jekyll reads the file to be transformed into CSS later ---- - -$baseurl: "{{ site.baseurl }}"; - -// 1. Custom variables and variable overwrites. -@import "https://github.com/theme/variables"; - - -// 2. Import default variables and available mixins. -@import "https://github.com/uikit/variables-theme"; -@import "https://github.com/uikit/mixins-theme"; - -// 3. Import Custom theme for UIkit. -@import "https://github.com/theme/uikit"; -@import "https://github.com/theme/mixins"; - 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/images/cs.png b/docs/assets/images/cs.png deleted file mode 100644 index 9b68819645..0000000000 Binary files a/docs/assets/images/cs.png and /dev/null differ diff --git a/docs/assets/images/event-sources.png b/docs/assets/images/event-sources.png deleted file mode 100644 index 773eaeb106..0000000000 Binary files a/docs/assets/images/event-sources.png and /dev/null differ diff --git a/docs/assets/images/logo-icon.svg b/docs/assets/images/logo-icon.svg deleted file mode 100644 index 245127232e..0000000000 --- a/docs/assets/images/logo-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/assets/images/logo-white.svg b/docs/assets/images/logo-white.svg deleted file mode 100644 index 87a3378dff..0000000000 --- a/docs/assets/images/logo-white.svg +++ /dev/null @@ -1 +0,0 @@ -JAVA OPERATOR SDK \ No newline at end of file diff --git a/docs/assets/js/prism.js b/docs/assets/js/prism.js deleted file mode 100644 index d597fce07e..0000000000 --- a/docs/assets/js/prism.js +++ /dev/null @@ -1,23 +0,0 @@ -/* PrismJS 1.24.1 -https://prismjs.com/download.html#themes=prism-solarizedlight&languages=markup+clike+bash+git+groovy+handlebars+java+javadoc+javadoclike+javastacktrace+json+kotlin+markup-templating+nginx+shell-session+xml-doc+yaml&plugins=normalize-whitespace+toolbar+copy-to-clipboard */ -var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(u){var c=/\blang(?:uage)?-([\w-]+)\b/i,n=0,e={},M={manual:u.Prism&&u.Prism.manual,disableWorkerMessageHandler:u.Prism&&u.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof W?new W(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=l.reach);y+=m.value.length,m=m.next){var b=m.value;if(t.length>n.length)return;if(!(b instanceof W)){var k,x=1;if(h){if(!(k=z(v,y,n,f)))break;var w=k.index,A=k.index+k[0].length,P=y;for(P+=m.value.length;P<=w;)m=m.next,P+=m.value.length;if(P-=m.value.length,y=P,m.value instanceof W)continue;for(var E=m;E!==t.tail&&(Pl.reach&&(l.reach=N);var j=m.prev;O&&(j=I(t,j,O),y+=O.length),q(t,j,x);var C=new W(o,g?M.tokenize(S,g):S,d,S);if(m=I(t,j,C),L&&I(t,m,L),1l.reach&&(l.reach=_.reach)}}}}}}(e,a,n,a.head,0),function(e){var n=[],t=e.head.next;for(;t!==e.tail;)n.push(t.value),t=t.next;return n}(a)},hooks:{all:{},add:function(e,n){var t=M.hooks.all;t[e]=t[e]||[],t[e].push(n)},run:function(e,n){var t=M.hooks.all[e];if(t&&t.length)for(var r,a=0;r=t[a++];)r(n)}},Token:W};function W(e,n,t,r){this.type=e,this.content=n,this.alias=t,this.length=0|(r||"").length}function z(e,n,t,r){e.lastIndex=n;var a=e.exec(t);if(a&&r&&a[1]){var i=a[1].length;a.index+=i,a[0]=a[0].slice(i)}return a}function i(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function I(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function q(e,n,t){for(var r=n.next,a=0;a"+a.content+""},!u.document)return u.addEventListener&&(M.disableWorkerMessageHandler||u.addEventListener("message",function(e){var n=JSON.parse(e.data),t=n.language,r=n.code,a=n.immediateClose;u.postMessage(M.highlight(r,M.languages[t],t)),a&&u.close()},!1)),M;var t=M.util.currentScript();function r(){M.manual||M.highlightAll()}if(t&&(M.filename=t.src,t.hasAttribute("data-manual")&&(M.manual=!0)),!M.manual){var a=document.readyState;"loading"===a||"interactive"===a&&t&&t.defer?document.addEventListener("DOMContentLoaded",r):window.requestAnimationFrame?window.requestAnimationFrame(r):window.setTimeout(r,16)}return M}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); -Prism.languages.markup={comment://,prolog:/<\?[\s\S]+?\?>/,doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/,name:/[^\s<>'"]+/}},cdata://i,tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))}),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^$/i;var t={"included-cdata":{pattern://i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:))*\\]\\]>|(?!)".replace(/__/g,function(){return a}),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml; -Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|interface|extends|implements|trait|instanceof|new)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,boolean:/\b(?:true|false)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; -!function(e){var t="\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b",n={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:"punctuation",inside:null},a={bash:n,environment:{pattern:RegExp("\\$"+t),alias:"constant"},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp("(\\{)"+t),lookbehind:!0,alias:"constant"}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|x[0-9a-fA-F]{1,2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:"important"},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:"function"},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:"function"}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:"variable",lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?=\+?=)/,inside:{environment:{pattern:RegExp("(^|[\\s;|&]|[<>]\\()"+t),lookbehind:!0,alias:"constant"}},alias:"variable",lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:a},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:n}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:a},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:a.entity}}],environment:{pattern:RegExp("\\$?"+t),alias:"constant"},variable:a.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|aptitude|apt-cache|apt-get|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:if|then|else|elif|fi|for|while|in|case|esac|function|select|do|done|until)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|break|cd|continue|eval|exec|exit|export|getopts|hash|pwd|readonly|return|shift|test|times|trap|umask|unset|alias|bind|builtin|caller|command|declare|echo|enable|help|let|local|logout|mapfile|printf|read|readarray|source|type|typeset|ulimit|unalias|set|shopt)(?=$|[)\s;|&])/,lookbehind:!0,alias:"class-name"},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:true|false)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:"important"},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:"important"}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},n.inside=e.languages.bash;for(var s=["comment","function-name","for-or-select","assign-left","string","environment","function","keyword","builtin","boolean","file-descriptor","operator","punctuation","number"],i=a.variable[1].inside,o=0;o]?|\+[+=]?|!=?|<(?:<=?|=>?)?|>(?:>>?=?|=)?|&[&=]?|\|[|=]?|\/=?|\^=?|%=?)/,lookbehind:!0},punctuation:/\.+|[{}[\];(),:$]/}),Prism.languages.insertBefore("groovy","string",{shebang:{pattern:/#!.+/,alias:"comment"}}),Prism.languages.insertBefore("groovy","punctuation",{"spock-block":/\b(?:setup|given|when|then|and|cleanup|expect|where):/}),Prism.languages.insertBefore("groovy","function",{annotation:{pattern:/(^|[^.])@\w+/,lookbehind:!0,alias:"punctuation"}}),Prism.hooks.add("wrap",function(e){if("groovy"===e.language&&"string"===e.type){var t=e.content[0];if("'"!=t){var n=/([^\\])(?:\$(?:\{.*?\}|[\w.]+))/;"$"===t&&(n=/([^\$])(?:\$(?:\{.*?\}|[\w.]+))/),e.content=e.content.replace(/</g,"<").replace(/&/g,"&"),e.content=Prism.highlight(e.content,{expression:{pattern:n,lookbehind:!0,inside:Prism.languages.groovy}}),e.classes.push("/"===t?"regex":"gstring")}}}); -!function(h){function v(e,n){return"___"+e.toUpperCase()+n+"___"}Object.defineProperties(h.languages["markup-templating"]={},{buildPlaceholders:{value:function(a,r,e,o){if(a.language===r){var c=a.tokenStack=[];a.code=a.code.replace(e,function(e){if("function"==typeof o&&!o(e))return e;for(var n,t=c.length;-1!==a.code.indexOf(n=v(r,t));)++t;return c[t]=e,n}),a.grammar=h.languages.markup}}},tokenizePlaceholders:{value:function(p,k){if(p.language===k&&p.tokenStack){p.grammar=h.languages[k];var m=0,d=Object.keys(p.tokenStack);!function e(n){for(var t=0;t=d.length);t++){var a=n[t];if("string"==typeof a||a.content&&"string"==typeof a.content){var r=d[m],o=p.tokenStack[r],c="string"==typeof a?a:a.content,i=v(k,r),u=c.indexOf(i);if(-1@\[\\\]^`{|}~]/,variable:/[^!"#%&'()*+,\/;<=>@\[\\\]^`{|}~\s]+/},e.hooks.add("before-tokenize",function(a){e.languages["markup-templating"].buildPlaceholders(a,"handlebars",/\{\{\{[\s\S]+?\}\}\}|\{\{[\s\S]+?\}\}/g)}),e.hooks.add("after-tokenize",function(a){e.languages["markup-templating"].tokenizePlaceholders(a,"handlebars")}),e.languages.hbs=e.languages.handlebars}(Prism); -!function(e){var t=/\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|exports|extends|final|finally|float|for|goto|if|implements|import|instanceof|int|interface|long|module|native|new|non-sealed|null|open|opens|package|permits|private|protected|provides|public|record|requires|return|sealed|short|static|strictfp|super|switch|synchronized|this|throw|throws|to|transient|transitive|try|uses|var|void|volatile|while|with|yield)\b/,n="(^|[^\\w.])(?:[a-z]\\w*\\s*\\.\\s*)*(?:[A-Z]\\w*\\s*\\.\\s*)*",a={pattern:RegExp(n+"[A-Z](?:[\\d_A-Z]*[a-z]\\w*)?\\b"),lookbehind:!0,inside:{namespace:{pattern:/^[a-z]\w*(?:\s*\.\s*[a-z]\w*)*(?:\s*\.)?/,inside:{punctuation:/\./}},punctuation:/\./}};e.languages.java=e.languages.extend("clike",{"class-name":[a,{pattern:RegExp(n+"[A-Z]\\w*(?=\\s+\\w+\\s*[;,=()])"),lookbehind:!0,inside:a.inside}],keyword:t,function:[e.languages.clike.function,{pattern:/(::\s*)[a-z_]\w*/,lookbehind:!0}],number:/\b0b[01][01_]*L?\b|\b0x(?:\.[\da-f_p+-]+|[\da-f_]+(?:\.[\da-f_p+-]+)?)\b|(?:\b\d[\d_]*(?:\.[\d_]*)?|\B\.\d[\d_]*)(?:e[+-]?\d[\d_]*)?[dfl]?/i,operator:{pattern:/(^|[^.])(?:<<=?|>>>?=?|->|--|\+\+|&&|\|\||::|[?:~]|[-+*/%&|^!=<>]=?)/m,lookbehind:!0}}),e.languages.insertBefore("java","string",{"triple-quoted-string":{pattern:/"""[ \t]*[\r\n](?:(?:"|"")?(?:\\.|[^"\\]))*"""/,greedy:!0,alias:"string"}}),e.languages.insertBefore("java","class-name",{annotation:{pattern:/(^|[^.])@\w+(?:\s*\.\s*\w+)*/,lookbehind:!0,alias:"punctuation"},generics:{pattern:/<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&)|<(?:[\w\s,.?]|&(?!&))*>)*>)*>)*>/,inside:{"class-name":a,keyword:t,punctuation:/[<>(),.:]/,operator:/[?&|]/}},namespace:{pattern:RegExp("(\\b(?:exports|import(?:\\s+static)?|module|open|opens|package|provides|requires|to|transitive|uses|with)\\s+)(?!)[a-z]\\w*(?:\\.[a-z]\\w*)*\\.?".replace(//g,function(){return t.source})),lookbehind:!0,inside:{punctuation:/\./}}})}(Prism); -!function(p){var a=p.languages.javadoclike={parameter:{pattern:/(^[\t ]*(?:\/{3}|\*|\/\*\*)\s*@(?:param|arg|arguments)\s+)\w+/m,lookbehind:!0},keyword:{pattern:/(^[\t ]*(?:\/{3}|\*|\/\*\*)\s*|\{)@[a-z][a-zA-Z-]+\b/m,lookbehind:!0},punctuation:/[{}]/};Object.defineProperty(a,"addSupport",{value:function(a,e){"string"==typeof a&&(a=[a]),a.forEach(function(a){!function(a,e){var n="doc-comment",t=p.languages[a];if(t){var r=t[n];if(!r){var o={"doc-comment":{pattern:/(^|[^\\])\/\*\*[^/][\s\S]*?(?:\*\/|$)/,lookbehind:!0,alias:"comment"}};r=(t=p.languages.insertBefore(a,"comment",o))[n]}if(r instanceof RegExp&&(r=t[n]={pattern:r}),Array.isArray(r))for(var i=0,s=r.length;i/g,function(){return"#\\s*\\w+(?:\\s*\\([^()]*\\))?"});a.languages.javadoc=a.languages.extend("javadoclike",{}),a.languages.insertBefore("javadoc","keyword",{reference:{pattern:RegExp("(@(?:exception|throws|see|link|linkplain|value)\\s+(?:\\*\\s*)?)(?:"+n+")"),lookbehind:!0,inside:{function:{pattern:/(#\s*)\w+(?=\s*\()/,lookbehind:!0},field:{pattern:/(#\s*)\w+/,lookbehind:!0},namespace:{pattern:/\b(?:[a-z]\w*\s*\.\s*)+/,inside:{punctuation:/\./}},"class-name":/\b[A-Z]\w*/,keyword:a.languages.java.keyword,punctuation:/[#()[\],.]/}},"class-name":{pattern:/(@param\s+)<[A-Z]\w*>/,lookbehind:!0,inside:{punctuation:/[.<>]/}},"code-section":[{pattern:/(\{@code\s+(?!\s))(?:[^\s{}]|\s+(?![\s}])|\{(?:[^{}]|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\})*\})+(?=\s*\})/,lookbehind:!0,inside:{code:{pattern:e,lookbehind:!0,inside:a.languages.java,alias:"language-java"}}},{pattern:/(<(code|pre|tt)>(?!)\s*)\S(?:\S|\s+\S)*?(?=\s*<\/\2>)/,lookbehind:!0,inside:{line:{pattern:e,lookbehind:!0,inside:{tag:a.languages.markup.tag,entity:a.languages.markup.entity,code:{pattern:/.+/,inside:a.languages.java,alias:"language-java"}}}}}],tag:a.languages.markup.tag,entity:a.languages.markup.entity}),a.languages.javadoclike.addSupport("java",a.languages.javadoc)}(Prism); -Prism.languages.javastacktrace={summary:{pattern:/^[\t ]*(?:(?:Caused by:|Suppressed:|Exception in thread "[^"]*")[\t ]+)?[\w$.]+(?::.*)?$/m,inside:{keyword:{pattern:/^(\s*)(?:(?:Caused by|Suppressed)(?=:)|Exception in thread)/m,lookbehind:!0},string:{pattern:/^(\s*)"[^"]*"/,lookbehind:!0},exceptions:{pattern:/^(:?\s*)[\w$.]+(?=:|$)/,lookbehind:!0,inside:{"class-name":/[\w$]+(?=$|:)/,namespace:/[a-z]\w*/,punctuation:/[.:]/}},message:{pattern:/(:\s*)\S.*/,lookbehind:!0,alias:"string"},punctuation:/:/}},"stack-frame":{pattern:/^[\t ]*at (?:[\w$./]|@[\w$.+-]*\/)+(?:)?\([^()]*\)/m,inside:{keyword:{pattern:/^(\s*)at(?= )/,lookbehind:!0},source:[{pattern:/(\()\w+\.\w+:\d+(?=\))/,lookbehind:!0,inside:{file:/^\w+\.\w+/,punctuation:/:/,"line-number":{pattern:/\d+/,alias:"number"}}},{pattern:/(\()[^()]*(?=\))/,lookbehind:!0,inside:{keyword:/^(?:Unknown Source|Native Method)$/}}],"class-name":/[\w$]+(?=\.(?:|[\w$]+)\()/,function:/(?:|[\w$]+)(?=\()/,"class-loader":{pattern:/(\s)[a-z]\w*(?:\.[a-z]\w*)*(?=\/[\w@$.]*\/)/,lookbehind:!0,alias:"namespace",inside:{punctuation:/\./}},module:{pattern:/([\s/])[a-z]\w*(?:\.[a-z]\w*)*(?:@[\w$.+-]*)?(?=\/)/,lookbehind:!0,inside:{version:{pattern:/(@)[\s\S]+/,lookbehind:!0,alias:"number"},punctuation:/[@.]/}},namespace:{pattern:/(?:[a-z]\w*\.)+/,inside:{punctuation:/\./}},punctuation:/[()/.]/}},more:{pattern:/^[\t ]*\.{3} \d+ [a-z]+(?: [a-z]+)*/m,inside:{punctuation:/\.{3}/,number:/\d+/,keyword:/\b[a-z]+(?: [a-z]+)*\b/}}}; -Prism.languages.json={property:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?=\s*:)/,lookbehind:!0,greedy:!0},string:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,lookbehind:!0,greedy:!0},comment:{pattern:/\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},number:/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,punctuation:/[{}[\],]/,operator:/:/,boolean:/\b(?:true|false)\b/,null:{pattern:/\bnull\b/,alias:"keyword"}},Prism.languages.webmanifest=Prism.languages.json; -!function(e){e.languages.kotlin=e.languages.extend("clike",{keyword:{pattern:/(^|[^.])\b(?:abstract|actual|annotation|as|break|by|catch|class|companion|const|constructor|continue|crossinline|data|do|dynamic|else|enum|expect|external|final|finally|for|fun|get|if|import|in|infix|init|inline|inner|interface|internal|is|lateinit|noinline|null|object|open|operator|out|override|package|private|protected|public|reified|return|sealed|set|super|suspend|tailrec|this|throw|to|try|typealias|val|var|vararg|when|where|while)\b/,lookbehind:!0},function:[{pattern:/(?:`[^\r\n`]+`|\b\w+)(?=\s*\()/,greedy:!0},{pattern:/(\.)(?:`[^\r\n`]+`|\w+)(?=\s*\{)/,lookbehind:!0,greedy:!0}],number:/\b(?:0[xX][\da-fA-F]+(?:_[\da-fA-F]+)*|0[bB][01]+(?:_[01]+)*|\d+(?:_\d+)*(?:\.\d+(?:_\d+)*)?(?:[eE][+-]?\d+(?:_\d+)*)?[fFL]?)\b/,operator:/\+[+=]?|-[-=>]?|==?=?|!(?:!|==?)?|[\/*%<>]=?|[?:]:?|\.\.|&&|\|\||\b(?:and|inv|or|shl|shr|ushr|xor)\b/}),delete e.languages.kotlin["class-name"],e.languages.insertBefore("kotlin","string",{"raw-string":{pattern:/("""|''')[\s\S]*?\1/,alias:"string"}}),e.languages.insertBefore("kotlin","keyword",{annotation:{pattern:/\B@(?:\w+:)?(?:[A-Z]\w*|\[[^\]]+\])/,alias:"builtin"}}),e.languages.insertBefore("kotlin","function",{label:{pattern:/\b\w+@|@\w+\b/,alias:"symbol"}});var n=[{pattern:/\$\{[^}]+\}/,inside:{delimiter:{pattern:/^\$\{|\}$/,alias:"variable"},rest:e.languages.kotlin}},{pattern:/\$\w+/,alias:"variable"}];e.languages.kotlin.string.inside=e.languages.kotlin["raw-string"].inside={interpolation:n},e.languages.kt=e.languages.kotlin,e.languages.kts=e.languages.kotlin}(Prism); -!function(e){var n=/\$(?:\w[a-z\d]*(?:_[^\x00-\x1F\s"'\\()$]*)?|\{[^}\s"'\\]+\})/i;Prism.languages.nginx={comment:{pattern:/(^|[\s{};])#.*/,lookbehind:!0},directive:{pattern:/(^|\s)\w(?:[^;{}"'\\\s]|\\.|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\s+(?:#.*(?!.)|(?![#\s])))*?(?=\s*[;{])/,lookbehind:!0,greedy:!0,inside:{string:{pattern:/((?:^|[^\\])(?:\\\\)*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/,lookbehind:!0,inside:{escape:{pattern:/\\["'\\nrt]/,alias:"entity"},variable:n}},comment:{pattern:/(\s)#.*/,lookbehind:!0,greedy:!0},keyword:{pattern:/^\S+/,greedy:!0},boolean:{pattern:/(\s)(?:off|on)(?!\S)/,lookbehind:!0},number:{pattern:/(\s)\d+[a-z]*(?!\S)/i,lookbehind:!0},variable:n}},punctuation:/[{};]/}}(); -!function(s){var n=['"(?:\\\\[^]|\\$\\([^)]+\\)|\\$(?!\\()|`[^`]+`|[^"\\\\`$])*"',"'[^']*'","\\$'(?:[^'\\\\]|\\\\[^])*'","<<-?\\s*([\"']?)(\\w+)\\1\\s[^]*?[\r\n]\\2"].join("|");s.languages["shell-session"]={command:{pattern:RegExp('^(?:[^\\s@:$#*!/\\\\]+@[^\r\n@:$#*!/\\\\]+(?::[^\0-\\x1F$#*?"<>:;|]+)?|[^\0-\\x1F$#*?"<>@:;|]+)?[$#]'+"(?:[^\\\\\r\n'\"<$]|\\\\(?:[^\r]|\r\n?)|\\$(?!')|<>)+".replace(/<>/g,function(){return n}),"m"),greedy:!0,inside:{info:{pattern:/^[^#$]+/,alias:"punctuation",inside:{user:/^[^\s@:$#*!/\\]+@[^\r\n@:$#*!/\\]+/,punctuation:/:/,path:/[\s\S]+/}},bash:{pattern:/(^[$#]\s*)\S[\s\S]*/,lookbehind:!0,alias:"language-bash",inside:s.languages.bash},"shell-symbol":{pattern:/^[$#]/,alias:"important"}}},output:/.(?:.*(?:[\r\n]|.$))*/},s.languages["sh-session"]=s.languages.shellsession=s.languages["shell-session"]}(Prism); -!function(n){function a(a,e){n.languages[a]&&n.languages.insertBefore(a,"comment",{"doc-comment":e})}var e=n.languages.markup.tag,t={pattern:/\/\/\/.*/,greedy:!0,alias:"comment",inside:{tag:e}},g={pattern:/'''.*/,greedy:!0,alias:"comment",inside:{tag:e}};a("csharp",t),a("fsharp",t),a("vbnet",g)}(Prism); -!function(e){var n=/[*&][^\s[\]{},]+/,r=/!(?:<[\w\-%#;/?:@&=+$,.!~*'()[\]]+>|(?:[a-zA-Z\d-]*!)?[\w\-%#;/?:@&=+$.~*'()]+)?/,t="(?:"+r.source+"(?:[ \t]+"+n.source+")?|"+n.source+"(?:[ \t]+"+r.source+")?)",a="(?:[^\\s\\x00-\\x08\\x0e-\\x1f!\"#%&'*,\\-:>?@[\\]`{|}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff]|[?:-])(?:[ \t]*(?:(?![#:])|:))*".replace(//g,function(){return"[^\\s\\x00-\\x08\\x0e-\\x1f,[\\]{}\\x7f-\\x84\\x86-\\x9f\\ud800-\\udfff\\ufffe\\uffff]"}),d="\"(?:[^\"\\\\\r\n]|\\\\.)*\"|'(?:[^'\\\\\r\n]|\\\\.)*'";function o(e,n){n=(n||"").replace(/m/g,"")+"m";var r="([:\\-,[{]\\s*(?:\\s<>[ \t]+)?)(?:<>)(?=[ \t]*(?:$|,|\\]|\\}|(?:[\r\n]\\s*)?#))".replace(/<>/g,function(){return t}).replace(/<>/g,function(){return e});return RegExp(r,n)}e.languages.yaml={scalar:{pattern:RegExp("([\\-:]\\s*(?:\\s<>[ \t]+)?[|>])[ \t]*(?:((?:\r?\n|\r)[ \t]+)\\S[^\r\n]*(?:\\2[^\r\n]+)*)".replace(/<>/g,function(){return t})),lookbehind:!0,alias:"string"},comment:/#.*/,key:{pattern:RegExp("((?:^|[:\\-,[{\r\n?])[ \t]*(?:<>[ \t]+)?)<>(?=\\s*:\\s)".replace(/<>/g,function(){return t}).replace(/<>/g,function(){return"(?:"+a+"|"+d+")"})),lookbehind:!0,greedy:!0,alias:"atrule"},directive:{pattern:/(^[ \t]*)%.+/m,lookbehind:!0,alias:"important"},datetime:{pattern:o("\\d{4}-\\d\\d?-\\d\\d?(?:[tT]|[ \t]+)\\d\\d?:\\d{2}:\\d{2}(?:\\.\\d*)?(?:[ \t]*(?:Z|[-+]\\d\\d?(?::\\d{2})?))?|\\d{4}-\\d{2}-\\d{2}|\\d\\d?:\\d{2}(?::\\d{2}(?:\\.\\d*)?)?"),lookbehind:!0,alias:"number"},boolean:{pattern:o("true|false","i"),lookbehind:!0,alias:"important"},null:{pattern:o("null|~","i"),lookbehind:!0,alias:"important"},string:{pattern:o(d),lookbehind:!0,greedy:!0},number:{pattern:o("[+-]?(?:0x[\\da-f]+|0o[0-7]+|(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?|\\.inf|\\.nan)","i"),lookbehind:!0},tag:r,important:n,punctuation:/---|[:[\]{}\-,|>?]|\.\.\./},e.languages.yml=e.languages.yaml}(Prism); -!function(){if("undefined"!=typeof Prism&&"undefined"!=typeof document){var i=Object.assign||function(e,n){for(var t in n)n.hasOwnProperty(t)&&(e[t]=n[t]);return e};e.prototype={setDefaults:function(e){this.defaults=i(this.defaults,e)},normalize:function(e,n){for(var t in n=i(this.defaults,n)){var r=t.replace(/-(\w)/g,function(e,n){return n.toUpperCase()});"normalize"!==t&&"setDefaults"!==r&&n[t]&&this[r]&&(e=this[r].call(this,e,n[t]))}return e},leftTrim:function(e){return e.replace(/^\s+/,"")},rightTrim:function(e){return e.replace(/\s+$/,"")},tabsToSpaces:function(e,n){return n=0|n||4,e.replace(/\t/g,new Array(++n).join(" "))},spacesToTabs:function(e,n){return n=0|n||4,e.replace(RegExp(" {"+n+"}","g"),"\t")},removeTrailing:function(e){return e.replace(/\s*?$/gm,"")},removeInitialLineFeed:function(e){return e.replace(/^(?:\r?\n|\r)/,"")},removeIndent:function(e){var n=e.match(/^[^\S\n\r]*(?=\S)/gm);return n&&n[0].length?(n.sort(function(e,n){return e.length-n.length}),n[0].length?e.replace(RegExp("^"+n[0],"gm"),""):e):e},indent:function(e,n){return e.replace(/^[^\S\n\r]*(?=\S)/gm,new Array(++n).join("\t")+"$&")},breakLines:function(e,n){n=!0===n?80:0|n||80;for(var t=e.split("\n"),r=0;r= 1; - } - - function isElement(obj) { - return nodeType(obj) === 1; - } - - function nodeType(obj) { - return !isWindow(obj) && isObject(obj) && obj.nodeType; - } - - function isBoolean(value) { - return typeof value === 'boolean'; - } - - function isString(value) { - return typeof value === 'string'; - } - - function isNumber(value) { - return typeof value === 'number'; - } - - function isNumeric(value) { - return isNumber(value) || isString(value) && !isNaN(value - parseFloat(value)); - } - - function isEmpty(obj) { - return !(isArray(obj) - ? obj.length - : isObject(obj) - ? Object.keys(obj).length - : false - ); - } - - function isUndefined(value) { - return value === void 0; - } - - function toBoolean(value) { - return isBoolean(value) - ? value - : value === 'true' || value === '1' || value === '' - ? true - : value === 'false' || value === '0' - ? false - : value; - } - - function toNumber(value) { - var number = Number(value); - return !isNaN(number) ? number : false; - } - - function toFloat(value) { - return parseFloat(value) || 0; - } - - var toArray = Array.from || (function (value) { return arrPrototype.slice.call(value); }); - - function toNode(element) { - return toNodes(element)[0]; - } - - function toNodes(element) { - return element && (isNode(element) ? [element] : toArray(element).filter(isNode)) || []; - } - - function toWindow(element) { - if (isWindow(element)) { - return element; - } - - element = toNode(element); - - return element - ? (isDocument(element) - ? element - : element.ownerDocument - ).defaultView - : window; - } - - function toMs(time) { - return !time - ? 0 - : endsWith(time, 'ms') - ? toFloat(time) - : toFloat(time) * 1000; - } - - function isEqual(value, other) { - return value === other - || isObject(value) - && isObject(other) - && Object.keys(value).length === Object.keys(other).length - && each(value, function (val, key) { return val === other[key]; }); - } - - function swap(value, a, b) { - return value.replace( - new RegExp((a + "|" + b), 'g'), - function (match) { return match === a ? b : a; } - ); - } - - var assign = Object.assign || function (target) { - var args = [], len = arguments.length - 1; - while ( len-- > 0 ) args[ len ] = arguments[ len + 1 ]; - - target = Object(target); - for (var i = 0; i < args.length; i++) { - var source = args[i]; - if (source !== null) { - for (var key in source) { - if (hasOwn(source, key)) { - target[key] = source[key]; - } - } - } - } - return target; - }; - - function last(array) { - return array[array.length - 1]; - } - - function each(obj, cb) { - for (var key in obj) { - if (false === cb(obj[key], key)) { - return false; - } - } - return true; - } - - function sortBy$1(array, prop) { - return array.slice().sort(function (ref, ref$1) { - var propA = ref[prop]; if ( propA === void 0 ) propA = 0; - var propB = ref$1[prop]; if ( propB === void 0 ) propB = 0; - - return propA > propB - ? 1 - : propB > propA - ? -1 - : 0; - } - ); - } - - function uniqueBy(array, prop) { - var seen = new Set(); - return array.filter(function (ref) { - var check = ref[prop]; - - return seen.has(check) - ? false - : seen.add(check) || true; - } // IE 11 does not return the Set object - ); - } - - function clamp(number, min, max) { - if ( min === void 0 ) min = 0; - if ( max === void 0 ) max = 1; - - return Math.min(Math.max(toNumber(number) || 0, min), max); - } - - function noop() {} - - function intersectRect() { - var rects = [], len = arguments.length; - while ( len-- ) rects[ len ] = arguments[ len ]; - - return [['bottom', 'top'], ['right', 'left']].every(function (ref) { - var minProp = ref[0]; - var maxProp = ref[1]; - - return Math.min.apply(Math, rects.map(function (ref) { - var min = ref[minProp]; - - return min; - })) - Math.max.apply(Math, rects.map(function (ref) { - var max = ref[maxProp]; - - return max; - })) > 0; - } - ); - } - - function pointInRect(point, rect) { - return point.x <= rect.right && - point.x >= rect.left && - point.y <= rect.bottom && - point.y >= rect.top; - } - - var Dimensions = { - - ratio: function(dimensions, prop, value) { - var obj; - - - var aProp = prop === 'width' ? 'height' : 'width'; - - return ( obj = {}, obj[aProp] = dimensions[prop] ? Math.round(value * dimensions[aProp] / dimensions[prop]) : dimensions[aProp], obj[prop] = value, obj ); - }, - - contain: function(dimensions, maxDimensions) { - var this$1 = this; - - dimensions = assign({}, dimensions); - - each(dimensions, function (_, prop) { return dimensions = dimensions[prop] > maxDimensions[prop] - ? this$1.ratio(dimensions, prop, maxDimensions[prop]) - : dimensions; } - ); - - return dimensions; - }, - - cover: function(dimensions, maxDimensions) { - var this$1 = this; - - dimensions = this.contain(dimensions, maxDimensions); - - each(dimensions, function (_, prop) { return dimensions = dimensions[prop] < maxDimensions[prop] - ? this$1.ratio(dimensions, prop, maxDimensions[prop]) - : dimensions; } - ); - - return dimensions; - } - - }; - - function getIndex(i, elements, current, finite) { - if ( current === void 0 ) current = 0; - if ( finite === void 0 ) finite = false; - - - elements = toNodes(elements); - - var length = elements.length; - - i = isNumeric(i) - ? toNumber(i) - : i === 'next' - ? current + 1 - : i === 'previous' - ? current - 1 - : elements.indexOf(toNode(i)); - - if (finite) { - return clamp(i, 0, length - 1); - } - - i %= length; - - return i < 0 ? i + length : i; - } - - function memoize(fn) { - var cache = Object.create(null); - return function (key) { return cache[key] || (cache[key] = fn(key)); }; - } - - function attr(element, name, value) { - - if (isObject(name)) { - for (var key in name) { - attr(element, key, name[key]); - } - return; - } - - if (isUndefined(value)) { - element = toNode(element); - return element && element.getAttribute(name); - } else { - toNodes(element).forEach(function (element) { - - if (isFunction(value)) { - value = value.call(element, attr(element, name)); - } - - if (value === null) { - removeAttr(element, name); - } else { - element.setAttribute(name, value); - } - }); - } - - } - - function hasAttr(element, name) { - return toNodes(element).some(function (element) { return element.hasAttribute(name); }); - } - - function removeAttr(element, name) { - element = toNodes(element); - name.split(' ').forEach(function (name) { return element.forEach(function (element) { return element.hasAttribute(name) && element.removeAttribute(name); } - ); } - ); - } - - function data(element, attribute) { - for (var i = 0, attrs = [attribute, ("data-" + attribute)]; i < attrs.length; i++) { - if (hasAttr(element, attrs[i])) { - return attr(element, attrs[i]); - } - } - } - - /* global DocumentTouch */ - - var inBrowser = typeof window !== 'undefined'; - var isIE = inBrowser && /msie|trident/i.test(window.navigator.userAgent); - var isRtl = inBrowser && attr(document.documentElement, 'dir') === 'rtl'; - - var hasTouchEvents = inBrowser && 'ontouchstart' in window; - var hasPointerEvents = inBrowser && window.PointerEvent; - var hasTouch = inBrowser && (hasTouchEvents - || window.DocumentTouch && document instanceof DocumentTouch - || navigator.maxTouchPoints); // IE >=11 - - var pointerDown = hasPointerEvents ? 'pointerdown' : hasTouchEvents ? 'touchstart' : 'mousedown'; - var pointerMove = hasPointerEvents ? 'pointermove' : hasTouchEvents ? 'touchmove' : 'mousemove'; - var pointerUp = hasPointerEvents ? 'pointerup' : hasTouchEvents ? 'touchend' : 'mouseup'; - var pointerEnter = hasPointerEvents ? 'pointerenter' : hasTouchEvents ? '' : 'mouseenter'; - var pointerLeave = hasPointerEvents ? 'pointerleave' : hasTouchEvents ? '' : 'mouseleave'; - var pointerCancel = hasPointerEvents ? 'pointercancel' : 'touchcancel'; - - var voidElements = { - area: true, - base: true, - br: true, - col: true, - embed: true, - hr: true, - img: true, - input: true, - keygen: true, - link: true, - menuitem: true, - meta: true, - param: true, - source: true, - track: true, - wbr: true - }; - function isVoidElement(element) { - return toNodes(element).some(function (element) { return voidElements[element.tagName.toLowerCase()]; }); - } - - function isVisible(element) { - return toNodes(element).some(function (element) { return element.offsetWidth || element.offsetHeight || element.getClientRects().length; }); - } - - var selInput = 'input,select,textarea,button'; - function isInput(element) { - return toNodes(element).some(function (element) { return matches(element, selInput); }); - } - - function isFocusable(element) { - return isInput(element) || matches(element, 'a[href],button') || hasAttr(element, 'tabindex'); - } - - function parent(element) { - element = toNode(element); - return element && isElement(element.parentNode) && element.parentNode; - } - - function filter$1(element, selector) { - return toNodes(element).filter(function (element) { return matches(element, selector); }); - } - - var elProto = inBrowser ? Element.prototype : {}; - var matchesFn = elProto.matches || elProto.webkitMatchesSelector || elProto.msMatchesSelector || noop; - - function matches(element, selector) { - return toNodes(element).some(function (element) { return matchesFn.call(element, selector); }); - } - - var closestFn = elProto.closest || function (selector) { - var ancestor = this; - - do { - - if (matches(ancestor, selector)) { - return ancestor; - } - - } while ((ancestor = parent(ancestor))); - }; - - function closest(element, selector) { - - if (startsWith(selector, '>')) { - selector = selector.slice(1); - } - - return isElement(element) - ? closestFn.call(element, selector) - : toNodes(element).map(function (element) { return closest(element, selector); }).filter(Boolean); - } - - function within(element, selector) { - return !isString(selector) - ? element === selector || (isDocument(selector) - ? selector.documentElement - : toNode(selector)).contains(toNode(element)) // IE 11 document does not implement contains - : matches(element, selector) || !!closest(element, selector); - } - - function parents(element, selector) { - var elements = []; - - while ((element = parent(element))) { - if (!selector || matches(element, selector)) { - elements.push(element); - } - } - - return elements; - } - - function children(element, selector) { - element = toNode(element); - var children = element ? toNodes(element.children) : []; - return selector ? filter$1(children, selector) : children; - } - - function index(element, ref) { - return ref - ? toNodes(element).indexOf(toNode(ref)) - : children(parent(element)).indexOf(element); - } - - function query(selector, context) { - return find(selector, getContext(selector, context)); - } - - function queryAll(selector, context) { - return findAll(selector, getContext(selector, context)); - } - - function getContext(selector, context) { - if ( context === void 0 ) context = document; - - return isString(selector) && isContextSelector(selector) || isDocument(context) - ? context - : context.ownerDocument; - } - - function find(selector, context) { - return toNode(_query(selector, context, 'querySelector')); - } - - function findAll(selector, context) { - return toNodes(_query(selector, context, 'querySelectorAll')); - } - - function _query(selector, context, queryFn) { - if ( context === void 0 ) context = document; - - - if (!selector || !isString(selector)) { - return selector; - } - - selector = selector.replace(contextSanitizeRe, '$1 *'); - - if (isContextSelector(selector)) { - - selector = splitSelector(selector).map(function (selector) { - - var ctx = context; - - if (selector[0] === '!') { - - var selectors = selector.substr(1).trim().split(' '); - ctx = closest(parent(context), selectors[0]); - selector = selectors.slice(1).join(' ').trim(); - - } - - if (selector[0] === '-') { - - var selectors$1 = selector.substr(1).trim().split(' '); - var prev = (ctx || context).previousElementSibling; - ctx = matches(prev, selector.substr(1)) ? prev : null; - selector = selectors$1.slice(1).join(' '); - - } - - if (!ctx) { - return null; - } - - return ((domPath(ctx)) + " " + selector); - - }).filter(Boolean).join(','); - - context = document; - - } - - try { - - return context[queryFn](selector); - - } catch (e) { - - return null; - - } - - } - - var contextSelectorRe = /(^|[^\\],)\s*[!>+~-]/; - var contextSanitizeRe = /([!>+~-])(?=\s+[!>+~-]|\s*$)/g; - - var isContextSelector = memoize(function (selector) { return selector.match(contextSelectorRe); }); - - var selectorRe = /.*?[^\\](?:,|$)/g; - - var splitSelector = memoize(function (selector) { return selector.match(selectorRe).map(function (selector) { return selector.replace(/,$/, '').trim(); } - ); } - ); - - function domPath(element) { - var names = []; - while (element.parentNode) { - if (element.id) { - names.unshift(("#" + (escape(element.id)))); - break; - } else { - var tagName = element.tagName; - if (tagName !== 'HTML') { - tagName += ":nth-child(" + (index(element) + 1) + ")"; - } - names.unshift(tagName); - element = element.parentNode; - } - } - return names.join(' > '); - } - - var escapeFn = inBrowser && window.CSS && CSS.escape || function (css) { return css.replace(/([^\x7f-\uFFFF\w-])/g, function (match) { return ("\\" + match); }); }; - function escape(css) { - return isString(css) ? escapeFn.call(null, css) : ''; - } - - function on() { - var args = [], len = arguments.length; - while ( len-- ) args[ len ] = arguments[ len ]; - - - var ref = getArgs(args); - var targets = ref[0]; - var type = ref[1]; - var selector = ref[2]; - var listener = ref[3]; - var useCapture = ref[4]; - - targets = toEventTargets(targets); - - if (listener.length > 1) { - listener = detail(listener); - } - - if (useCapture && useCapture.self) { - listener = selfFilter(listener); - } - - if (selector) { - listener = delegate(selector, listener); - } - - useCapture = useCaptureFilter(useCapture); - - type.split(' ').forEach(function (type) { return targets.forEach(function (target) { return target.addEventListener(type, listener, useCapture); } - ); } - ); - return function () { return off(targets, type, listener, useCapture); }; - } - - function off(targets, type, listener, useCapture) { - if ( useCapture === void 0 ) useCapture = false; - - useCapture = useCaptureFilter(useCapture); - targets = toEventTargets(targets); - type.split(' ').forEach(function (type) { return targets.forEach(function (target) { return target.removeEventListener(type, listener, useCapture); } - ); } - ); - } - - function once() { - var args = [], len = arguments.length; - while ( len-- ) args[ len ] = arguments[ len ]; - - - var ref = getArgs(args); - var element = ref[0]; - var type = ref[1]; - var selector = ref[2]; - var listener = ref[3]; - var useCapture = ref[4]; - var condition = ref[5]; - var off = on(element, type, selector, function (e) { - var result = !condition || condition(e); - if (result) { - off(); - listener(e, result); - } - }, useCapture); - - return off; - } - - function trigger(targets, event, detail) { - return toEventTargets(targets).reduce(function (notCanceled, target) { return notCanceled && target.dispatchEvent(createEvent(event, true, true, detail)); } - , true); - } - - function createEvent(e, bubbles, cancelable, detail) { - if ( bubbles === void 0 ) bubbles = true; - if ( cancelable === void 0 ) cancelable = false; - - if (isString(e)) { - var event = document.createEvent('CustomEvent'); // IE 11 - event.initCustomEvent(e, bubbles, cancelable, detail); - e = event; - } - - return e; - } - - function getArgs(args) { - if (isFunction(args[2])) { - args.splice(2, 0, false); - } - return args; - } - - function delegate(selector, listener) { - var this$1 = this; - - return function (e) { - - var current = selector[0] === '>' - ? findAll(selector, e.currentTarget).reverse().filter(function (element) { return within(e.target, element); })[0] - : closest(e.target, selector); - - if (current) { - e.current = current; - listener.call(this$1, e); - } - - }; - } - - function detail(listener) { - return function (e) { return isArray(e.detail) ? listener.apply(void 0, [ e ].concat( e.detail )) : listener(e); }; - } - - function selfFilter(listener) { - return function (e) { - if (e.target === e.currentTarget || e.target === e.current) { - return listener.call(null, e); - } - }; - } - - function useCaptureFilter(options) { - return options && isIE && !isBoolean(options) - ? !!options.capture - : options; - } - - function isEventTarget(target) { - return target && 'addEventListener' in target; - } - - function toEventTarget(target) { - return isEventTarget(target) ? target : toNode(target); - } - - function toEventTargets(target) { - return isArray(target) - ? target.map(toEventTarget).filter(Boolean) - : isString(target) - ? findAll(target) - : isEventTarget(target) - ? [target] - : toNodes(target); - } - - function isTouch(e) { - return e.pointerType === 'touch' || !!e.touches; - } - - function getEventPos(e) { - var touches = e.touches; - var changedTouches = e.changedTouches; - var ref = touches && touches[0] || changedTouches && changedTouches[0] || e; - var x = ref.clientX; - var y = ref.clientY; - - return {x: x, y: y}; - } - - /* global setImmediate */ - - var Promise$1 = inBrowser && window.Promise || PromiseFn; - - var Deferred = function() { - var this$1 = this; - - this.promise = new Promise$1(function (resolve, reject) { - this$1.reject = reject; - this$1.resolve = resolve; - }); - }; - - /** - * Promises/A+ polyfill v1.1.4 (https://github.com/bramstein/promis) - */ - - var RESOLVED = 0; - var REJECTED = 1; - var PENDING = 2; - - var async = inBrowser && window.setImmediate || setTimeout; - - function PromiseFn(executor) { - - this.state = PENDING; - this.value = undefined; - this.deferred = []; - - var promise = this; - - try { - executor( - function (x) { - promise.resolve(x); - }, - function (r) { - promise.reject(r); - } - ); - } catch (e) { - promise.reject(e); - } - } - - PromiseFn.reject = function (r) { - return new PromiseFn(function (resolve, reject) { - reject(r); - }); - }; - - PromiseFn.resolve = function (x) { - return new PromiseFn(function (resolve, reject) { - resolve(x); - }); - }; - - PromiseFn.all = function all(iterable) { - return new PromiseFn(function (resolve, reject) { - var result = []; - var count = 0; - - if (iterable.length === 0) { - resolve(result); - } - - function resolver(i) { - return function (x) { - result[i] = x; - count += 1; - - if (count === iterable.length) { - resolve(result); - } - }; - } - - for (var i = 0; i < iterable.length; i += 1) { - PromiseFn.resolve(iterable[i]).then(resolver(i), reject); - } - }); - }; - - PromiseFn.race = function race(iterable) { - return new PromiseFn(function (resolve, reject) { - for (var i = 0; i < iterable.length; i += 1) { - PromiseFn.resolve(iterable[i]).then(resolve, reject); - } - }); - }; - - var p = PromiseFn.prototype; - - p.resolve = function resolve(x) { - var promise = this; - - if (promise.state === PENDING) { - if (x === promise) { - throw new TypeError('Promise settled with itself.'); - } - - var called = false; - - try { - var then = x && x.then; - - if (x !== null && isObject(x) && isFunction(then)) { - then.call( - x, - function (x) { - if (!called) { - promise.resolve(x); - } - called = true; - }, - function (r) { - if (!called) { - promise.reject(r); - } - called = true; - } - ); - return; - } - } catch (e) { - if (!called) { - promise.reject(e); - } - return; - } - - promise.state = RESOLVED; - promise.value = x; - promise.notify(); - } - }; - - p.reject = function reject(reason) { - var promise = this; - - if (promise.state === PENDING) { - if (reason === promise) { - throw new TypeError('Promise settled with itself.'); - } - - promise.state = REJECTED; - promise.value = reason; - promise.notify(); - } - }; - - p.notify = function notify() { - var this$1 = this; - - async(function () { - if (this$1.state !== PENDING) { - while (this$1.deferred.length) { - var ref = this$1.deferred.shift(); - var onResolved = ref[0]; - var onRejected = ref[1]; - var resolve = ref[2]; - var reject = ref[3]; - - try { - if (this$1.state === RESOLVED) { - if (isFunction(onResolved)) { - resolve(onResolved.call(undefined, this$1.value)); - } else { - resolve(this$1.value); - } - } else if (this$1.state === REJECTED) { - if (isFunction(onRejected)) { - resolve(onRejected.call(undefined, this$1.value)); - } else { - reject(this$1.value); - } - } - } catch (e) { - reject(e); - } - } - } - }); - }; - - p.then = function then(onResolved, onRejected) { - var this$1 = this; - - return new PromiseFn(function (resolve, reject) { - this$1.deferred.push([onResolved, onRejected, resolve, reject]); - this$1.notify(); - }); - }; - - p.catch = function (onRejected) { - return this.then(undefined, onRejected); - }; - - function ajax(url, options) { - - var env = assign({ - data: null, - method: 'GET', - headers: {}, - xhr: new XMLHttpRequest(), - beforeSend: noop, - responseType: '' - }, options); - - return Promise$1.resolve() - .then(function () { return env.beforeSend(env); }) - .then(function () { return send(url, env); }); - } - - function send(url, env) { - return new Promise$1(function (resolve, reject) { - var xhr = env.xhr; - - for (var prop in env) { - if (prop in xhr) { - try { - - xhr[prop] = env[prop]; - - } catch (e) {} - } - } - - xhr.open(env.method.toUpperCase(), url); - - for (var header in env.headers) { - xhr.setRequestHeader(header, env.headers[header]); - } - - on(xhr, 'load', function () { - - if (xhr.status === 0 || xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) { - - // IE 11 does not support responseType 'json' - if (env.responseType === 'json' && isString(xhr.response)) { - xhr = assign(copyXhr(xhr), {response: JSON.parse(xhr.response)}); - } - - resolve(xhr); - - } else { - reject(assign(Error(xhr.statusText), { - xhr: xhr, - status: xhr.status - })); - } - - }); - - on(xhr, 'error', function () { return reject(assign(Error('Network Error'), {xhr: xhr})); }); - on(xhr, 'timeout', function () { return reject(assign(Error('Network Timeout'), {xhr: xhr})); }); - - xhr.send(env.data); - }); - } - - function getImage(src, srcset, sizes) { - - return new Promise$1(function (resolve, reject) { - var img = new Image(); - - img.onerror = function (e) { return reject(e); }; - img.onload = function () { return resolve(img); }; - - sizes && (img.sizes = sizes); - srcset && (img.srcset = srcset); - img.src = src; - }); - - } - - function copyXhr(source) { - var target = {}; - for (var key in source) { - target[key] = source[key]; - } - return target; - } - - function ready(fn) { - - if (document.readyState !== 'loading') { - fn(); - return; - } - - var unbind = on(document, 'DOMContentLoaded', function () { - unbind(); - fn(); - }); - } - - function empty(element) { - element = $(element); - element.innerHTML = ''; - return element; - } - - function html(parent, html) { - parent = $(parent); - return isUndefined(html) - ? parent.innerHTML - : append(parent.hasChildNodes() ? empty(parent) : parent, html); - } - - function prepend(parent, element) { - - parent = $(parent); - - if (!parent.hasChildNodes()) { - return append(parent, element); - } else { - return insertNodes(element, function (element) { return parent.insertBefore(element, parent.firstChild); }); - } - } - - function append(parent, element) { - parent = $(parent); - return insertNodes(element, function (element) { return parent.appendChild(element); }); - } - - function before(ref, element) { - ref = $(ref); - return insertNodes(element, function (element) { return ref.parentNode.insertBefore(element, ref); }); - } - - function after(ref, element) { - ref = $(ref); - return insertNodes(element, function (element) { return ref.nextSibling - ? before(ref.nextSibling, element) - : append(ref.parentNode, element); } - ); - } - - function insertNodes(element, fn) { - element = isString(element) ? fragment(element) : element; - return element - ? 'length' in element - ? toNodes(element).map(fn) - : fn(element) - : null; - } - - function remove$1(element) { - toNodes(element).forEach(function (element) { return element.parentNode && element.parentNode.removeChild(element); }); - } - - function wrapAll(element, structure) { - - structure = toNode(before(element, structure)); - - while (structure.firstChild) { - structure = structure.firstChild; - } - - append(structure, element); - - return structure; - } - - function wrapInner(element, structure) { - return toNodes(toNodes(element).map(function (element) { return element.hasChildNodes ? wrapAll(toNodes(element.childNodes), structure) : append(element, structure); } - )); - } - - function unwrap(element) { - toNodes(element) - .map(parent) - .filter(function (value, index, self) { return self.indexOf(value) === index; }) - .forEach(function (parent) { - before(parent, parent.childNodes); - remove$1(parent); - }); - } - - var fragmentRe = /^\s*<(\w+|!)[^>]*>/; - var singleTagRe = /^<(\w+)\s*\/?>(?:<\/\1>)?$/; - - function fragment(html) { - - var matches = singleTagRe.exec(html); - if (matches) { - return document.createElement(matches[1]); - } - - var container = document.createElement('div'); - if (fragmentRe.test(html)) { - container.insertAdjacentHTML('beforeend', html.trim()); - } else { - container.textContent = html; - } - - return container.childNodes.length > 1 ? toNodes(container.childNodes) : container.firstChild; - - } - - function apply$1(node, fn) { - - if (!isElement(node)) { - return; - } - - fn(node); - node = node.firstElementChild; - while (node) { - var next = node.nextElementSibling; - apply$1(node, fn); - node = next; - } - } - - function $(selector, context) { - return isHtml(selector) - ? toNode(fragment(selector)) - : find(selector, context); - } - - function $$(selector, context) { - return isHtml(selector) - ? toNodes(fragment(selector)) - : findAll(selector, context); - } - - function isHtml(str) { - return isString(str) && (str[0] === '<' || str.match(/^\s* 0 ) args[ len ] = arguments[ len + 1 ]; - - apply(element, args, 'add'); - } - - function removeClass(element) { - var args = [], len = arguments.length - 1; - while ( len-- > 0 ) args[ len ] = arguments[ len + 1 ]; - - apply(element, args, 'remove'); - } - - function removeClasses(element, cls) { - attr(element, 'class', function (value) { return (value || '').replace(new RegExp(("\\b" + cls + "\\b"), 'g'), ''); }); - } - - function replaceClass(element) { - var args = [], len = arguments.length - 1; - while ( len-- > 0 ) args[ len ] = arguments[ len + 1 ]; - - args[0] && removeClass(element, args[0]); - args[1] && addClass(element, args[1]); - } - - function hasClass(element, cls) { - var assign; - - (assign = getClasses(cls), cls = assign[0]); - var nodes = toNodes(element); - for (var n = 0; n < nodes.length; n++) { - if (cls && nodes[n].classList.contains(cls)) { - return true; - } - } - return false; - } - - function toggleClass(element, cls, force) { - - cls = getClasses(cls); - - var nodes = toNodes(element); - for (var n = 0; n < nodes.length; n++) { - var list = nodes[n].classList; - for (var i = 0; i < cls.length; i++) { - if (isUndefined(force)) { - list.toggle(cls[i]); - } else if (supports.Force) { - list.toggle(cls[i], !!force); - } else { - list[force ? 'add' : 'remove'](cls[i]); - } - } - } - } - - function apply(element, args, fn) { - var ref; - - - args = args.reduce(function (args, arg) { return args.concat(getClasses(arg)); }, []); - - var nodes = toNodes(element); - var loop = function ( n ) { - if (supports.Multiple) { - (ref = nodes[n].classList)[fn].apply(ref, args); - } else { - args.forEach(function (cls) { return nodes[n].classList[fn](cls); }); - } - }; - - for (var n = 0; n < nodes.length; n++) loop( n ); - } - - function getClasses(str) { - return String(str).split(/\s|,/).filter(Boolean); - } - - // IE 11 - var supports = { - - get Multiple() { - return this.get('Multiple'); - }, - - get Force() { - return this.get('Force'); - }, - - get: function(key) { - - var ref = document.createElement('_'); - var classList = ref.classList; - classList.add('a', 'b'); - classList.toggle('c', false); - supports = { - Multiple: classList.contains('b'), - Force: !classList.contains('c') - }; - - return supports[key]; - } - - }; - - var cssNumber = { - 'animation-iteration-count': true, - 'column-count': true, - 'fill-opacity': true, - 'flex-grow': true, - 'flex-shrink': true, - 'font-weight': true, - 'line-height': true, - 'opacity': true, - 'order': true, - 'orphans': true, - 'stroke-dasharray': true, - 'stroke-dashoffset': true, - 'widows': true, - 'z-index': true, - 'zoom': true - }; - - function css(element, property, value, priority) { - if ( priority === void 0 ) priority = ''; - - - return toNodes(element).map(function (element) { - - if (isString(property)) { - - property = propName(property); - - if (isUndefined(value)) { - return getStyle(element, property); - } else if (!value && !isNumber(value)) { - element.style.removeProperty(property); - } else { - element.style.setProperty(property, isNumeric(value) && !cssNumber[property] ? (value + "px") : value, priority); - } - - } else if (isArray(property)) { - - var styles = getStyles(element); - - return property.reduce(function (props, property) { - props[property] = styles[propName(property)]; - return props; - }, {}); - - } else if (isObject(property)) { - priority = value; - each(property, function (value, property) { return css(element, property, value, priority); }); - } - - return element; - - })[0]; - - } - - function getStyles(element, pseudoElt) { - return toWindow(element).getComputedStyle(element, pseudoElt); - } - - function getStyle(element, property, pseudoElt) { - return getStyles(element, pseudoElt)[property]; - } - - var parseCssVar = memoize(function (name) { - /* usage in css: .uk-name:before { content:"xyz" } */ - - var element = append(document.documentElement, document.createElement('div')); - - addClass(element, ("uk-" + name)); - - name = getStyle(element, 'content', ':before').replace(/^["'](.*)["']$/, '$1'); - - remove$1(element); - - return name; - }); - - function getCssVar(name) { - return !isIE - ? getStyles(document.documentElement).getPropertyValue(("--uk-" + name)) - : parseCssVar(name); - } - - // https://drafts.csswg.org/cssom/#dom-cssstyledeclaration-setproperty - var propName = memoize(function (name) { return vendorPropName(name); }); - - var cssPrefixes = ['webkit', 'moz', 'ms']; - - function vendorPropName(name) { - - name = hyphenate(name); - - var ref = document.documentElement; - var style = ref.style; - - if (name in style) { - return name; - } - - var i = cssPrefixes.length, prefixedName; - - while (i--) { - prefixedName = "-" + (cssPrefixes[i]) + "-" + name; - if (prefixedName in style) { - return prefixedName; - } - } - } - - function transition(element, props, duration, timing) { - if ( duration === void 0 ) duration = 400; - if ( timing === void 0 ) timing = 'linear'; - - - return Promise$1.all(toNodes(element).map(function (element) { return new Promise$1(function (resolve, reject) { - - for (var name in props) { - var value = css(element, name); - if (value === '') { - css(element, name, value); - } - } - - var timer = setTimeout(function () { return trigger(element, 'transitionend'); }, duration); - - once(element, 'transitionend transitioncanceled', function (ref) { - var type = ref.type; - - clearTimeout(timer); - removeClass(element, 'uk-transition'); - css(element, { - transitionProperty: '', - transitionDuration: '', - transitionTimingFunction: '' - }); - type === 'transitioncanceled' ? reject() : resolve(element); - }, {self: true}); - - addClass(element, 'uk-transition'); - css(element, assign({ - transitionProperty: Object.keys(props).map(propName).join(','), - transitionDuration: (duration + "ms"), - transitionTimingFunction: timing - }, props)); - - }); } - )); - - } - - var Transition = { - - start: transition, - - stop: function(element) { - trigger(element, 'transitionend'); - return Promise$1.resolve(); - }, - - cancel: function(element) { - trigger(element, 'transitioncanceled'); - }, - - inProgress: function(element) { - return hasClass(element, 'uk-transition'); - } - - }; - - var animationPrefix = 'uk-animation-'; - - function animate$1(element, animation, duration, origin, out) { - if ( duration === void 0 ) duration = 200; - - - return Promise$1.all(toNodes(element).map(function (element) { return new Promise$1(function (resolve, reject) { - - trigger(element, 'animationcanceled'); - var timer = setTimeout(function () { return trigger(element, 'animationend'); }, duration); - - once(element, 'animationend animationcanceled', function (ref) { - var type = ref.type; - - - clearTimeout(timer); - - type === 'animationcanceled' ? reject() : resolve(element); - - css(element, 'animationDuration', ''); - removeClasses(element, (animationPrefix + "\\S*")); - - }, {self: true}); - - css(element, 'animationDuration', (duration + "ms")); - addClass(element, animation, animationPrefix + (out ? 'leave' : 'enter')); - - if (startsWith(animation, animationPrefix)) { - origin && addClass(element, ("uk-transform-origin-" + origin)); - out && addClass(element, (animationPrefix + "reverse")); - } - - }); } - )); - - } - - var inProgress = new RegExp((animationPrefix + "(enter|leave)")); - var Animation = { - - in: animate$1, - - out: function(element, animation, duration, origin) { - return animate$1(element, animation, duration, origin, true); - }, - - inProgress: function(element) { - return inProgress.test(attr(element, 'class')); - }, - - cancel: function(element) { - trigger(element, 'animationcanceled'); - } - - }; - - var dirs$1 = { - width: ['left', 'right'], - height: ['top', 'bottom'] - }; - - function dimensions(element) { - - var rect = isElement(element) - ? toNode(element).getBoundingClientRect() - : {height: height(element), width: width(element), top: 0, left: 0}; - - return { - height: rect.height, - width: rect.width, - top: rect.top, - left: rect.left, - bottom: rect.top + rect.height, - right: rect.left + rect.width - }; - } - - function offset(element, coordinates) { - - var currentOffset = dimensions(element); - var ref = toWindow(element); - var pageYOffset = ref.pageYOffset; - var pageXOffset = ref.pageXOffset; - var offsetBy = {height: pageYOffset, width: pageXOffset}; - - for (var dir in dirs$1) { - for (var i in dirs$1[dir]) { - currentOffset[dirs$1[dir][i]] += offsetBy[dir]; - } - } - - if (!coordinates) { - return currentOffset; - } - - var pos = css(element, 'position'); - - each(css(element, ['left', 'top']), function (value, prop) { return css(element, prop, coordinates[prop] - - currentOffset[prop] - + toFloat(pos === 'absolute' && value === 'auto' - ? position(element)[prop] - : value) - ); } - ); - } - - function position(element) { - - var ref = offset(element); - var top = ref.top; - var left = ref.left; - - var ref$1 = toNode(element); - var ref$1_ownerDocument = ref$1.ownerDocument; - var body = ref$1_ownerDocument.body; - var documentElement = ref$1_ownerDocument.documentElement; - var offsetParent = ref$1.offsetParent; - var parent = offsetParent || documentElement; - - while (parent && (parent === body || parent === documentElement) && css(parent, 'position') === 'static') { - parent = parent.parentNode; - } - - if (isElement(parent)) { - var parentOffset = offset(parent); - top -= parentOffset.top + toFloat(css(parent, 'borderTopWidth')); - left -= parentOffset.left + toFloat(css(parent, 'borderLeftWidth')); - } - - return { - top: top - toFloat(css(element, 'marginTop')), - left: left - toFloat(css(element, 'marginLeft')) - }; - } - - function offsetPosition(element) { - var offset = [0, 0]; - - element = toNode(element); - - do { - - offset[0] += element.offsetTop; - offset[1] += element.offsetLeft; - - if (css(element, 'position') === 'fixed') { - var win = toWindow(element); - offset[0] += win.pageYOffset; - offset[1] += win.pageXOffset; - return offset; - } - - } while ((element = element.offsetParent)); - - return offset; - } - - var height = dimension('height'); - var width = dimension('width'); - - function dimension(prop) { - var propName = ucfirst(prop); - return function (element, value) { - - if (isUndefined(value)) { - - if (isWindow(element)) { - return element[("inner" + propName)]; - } - - if (isDocument(element)) { - var doc = element.documentElement; - return Math.max(doc[("offset" + propName)], doc[("scroll" + propName)]); - } - - element = toNode(element); - - value = css(element, prop); - value = value === 'auto' ? element[("offset" + propName)] : toFloat(value) || 0; - - return value - boxModelAdjust(element, prop); - - } else { - - return css(element, prop, !value && value !== 0 - ? '' - : +value + boxModelAdjust(element, prop) + 'px' - ); - - } - - }; - } - - function boxModelAdjust(element, prop, sizing) { - if ( sizing === void 0 ) sizing = 'border-box'; - - return css(element, 'boxSizing') === sizing - ? dirs$1[prop].map(ucfirst).reduce(function (value, prop) { return value - + toFloat(css(element, ("padding" + prop))) - + toFloat(css(element, ("border" + prop + "Width"))); } - , 0) - : 0; - } - - function flipPosition(pos) { - for (var dir in dirs$1) { - for (var i in dirs$1[dir]) { - if (dirs$1[dir][i] === pos) { - return dirs$1[dir][1 - i]; - } - } - } - return pos; - } - - function toPx(value, property, element) { - if ( property === void 0 ) property = 'width'; - if ( element === void 0 ) element = window; - - return isNumeric(value) - ? +value - : endsWith(value, 'vh') - ? percent(height(toWindow(element)), value) - : endsWith(value, 'vw') - ? percent(width(toWindow(element)), value) - : endsWith(value, '%') - ? percent(dimensions(element)[property], value) - : toFloat(value); - } - - function percent(base, value) { - return base * toFloat(value) / 100; - } - - /* - Based on: - Copyright (c) 2016 Wilson Page wilsonpage@me.com - https://github.com/wilsonpage/fastdom - */ - - var fastdom = { - - reads: [], - writes: [], - - read: function(task) { - this.reads.push(task); - scheduleFlush(); - return task; - }, - - write: function(task) { - this.writes.push(task); - scheduleFlush(); - return task; - }, - - clear: function(task) { - remove(this.reads, task); - remove(this.writes, task); - }, - - flush: flush - - }; - - function flush(recursion) { - if ( recursion === void 0 ) recursion = 1; - - runTasks(fastdom.reads); - runTasks(fastdom.writes.splice(0)); - - fastdom.scheduled = false; - - if (fastdom.reads.length || fastdom.writes.length) { - scheduleFlush(recursion + 1); - } - } - - var RECURSION_LIMIT = 4; - function scheduleFlush(recursion) { - - if (fastdom.scheduled) { - return; - } - - fastdom.scheduled = true; - if (recursion && recursion < RECURSION_LIMIT) { - Promise$1.resolve().then(function () { return flush(recursion); }); - } else { - requestAnimationFrame(function () { return flush(); }); - } - - } - - function runTasks(tasks) { - var task; - while ((task = tasks.shift())) { - try { - task(); - } catch (e) { - console.error(e); - } - } - } - - function remove(array, item) { - var index = array.indexOf(item); - return ~index && array.splice(index, 1); - } - - function MouseTracker() {} - - MouseTracker.prototype = { - - positions: [], - - init: function() { - var this$1 = this; - - - this.positions = []; - - var position; - this.unbind = on(document, 'mousemove', function (e) { return position = getEventPos(e); }); - this.interval = setInterval(function () { - - if (!position) { - return; - } - - this$1.positions.push(position); - - if (this$1.positions.length > 5) { - this$1.positions.shift(); - } - }, 50); - - }, - - cancel: function() { - this.unbind && this.unbind(); - this.interval && clearInterval(this.interval); - }, - - movesTo: function(target) { - - if (this.positions.length < 2) { - return false; - } - - var p = target.getBoundingClientRect(); - var left = p.left; - var right = p.right; - var top = p.top; - var bottom = p.bottom; - - var ref = this.positions; - var prevPosition = ref[0]; - var position = last(this.positions); - var path = [prevPosition, position]; - - if (pointInRect(position, p)) { - return false; - } - - var diagonals = [[{x: left, y: top}, {x: right, y: bottom}], [{x: left, y: bottom}, {x: right, y: top}]]; - - return diagonals.some(function (diagonal) { - var intersection = intersect(path, diagonal); - return intersection && pointInRect(intersection, p); - }); - } - - }; - - // Inspired by http://paulbourke.net/geometry/pointlineplane/ - function intersect(ref, ref$1) { - var ref_0 = ref[0]; - var x1 = ref_0.x; - var y1 = ref_0.y; - var ref_1 = ref[1]; - var x2 = ref_1.x; - var y2 = ref_1.y; - var ref$1_0 = ref$1[0]; - var x3 = ref$1_0.x; - var y3 = ref$1_0.y; - var ref$1_1 = ref$1[1]; - var x4 = ref$1_1.x; - var y4 = ref$1_1.y; - - - var denominator = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1); - - // Lines are parallel - if (denominator === 0) { - return false; - } - - var ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denominator; - - if (ua < 0) { - return false; - } - - // Return an object with the x and y coordinates of the intersection - return {x: x1 + ua * (x2 - x1), y: y1 + ua * (y2 - y1)}; - } - - var strats = {}; - - strats.events = - strats.created = - strats.beforeConnect = - strats.connected = - strats.beforeDisconnect = - strats.disconnected = - strats.destroy = concatStrat; - - // args strategy - strats.args = function (parentVal, childVal) { - return childVal !== false && concatStrat(childVal || parentVal); - }; - - // update strategy - strats.update = function (parentVal, childVal) { - return sortBy$1(concatStrat(parentVal, isFunction(childVal) ? {read: childVal} : childVal), 'order'); - }; - - // property strategy - strats.props = function (parentVal, childVal) { - - if (isArray(childVal)) { - childVal = childVal.reduce(function (value, key) { - value[key] = String; - return value; - }, {}); - } - - return strats.methods(parentVal, childVal); - }; - - // extend strategy - strats.computed = - strats.methods = function (parentVal, childVal) { - return childVal - ? parentVal - ? assign({}, parentVal, childVal) - : childVal - : parentVal; - }; - - // data strategy - strats.data = function (parentVal, childVal, vm) { - - if (!vm) { - - if (!childVal) { - return parentVal; - } - - if (!parentVal) { - return childVal; - } - - return function (vm) { - return mergeFnData(parentVal, childVal, vm); - }; - - } - - return mergeFnData(parentVal, childVal, vm); - }; - - function mergeFnData(parentVal, childVal, vm) { - return strats.computed( - isFunction(parentVal) - ? parentVal.call(vm, vm) - : parentVal, - isFunction(childVal) - ? childVal.call(vm, vm) - : childVal - ); - } - - // concat strategy - function concatStrat(parentVal, childVal) { - - parentVal = parentVal && !isArray(parentVal) ? [parentVal] : parentVal; - - return childVal - ? parentVal - ? parentVal.concat(childVal) - : isArray(childVal) - ? childVal - : [childVal] - : parentVal; - } - - // default strategy - function defaultStrat(parentVal, childVal) { - return isUndefined(childVal) ? parentVal : childVal; - } - - function mergeOptions(parent, child, vm) { - - var options = {}; - - if (isFunction(child)) { - child = child.options; - } - - if (child.extends) { - parent = mergeOptions(parent, child.extends, vm); - } - - if (child.mixins) { - for (var i = 0, l = child.mixins.length; i < l; i++) { - parent = mergeOptions(parent, child.mixins[i], vm); - } - } - - for (var key in parent) { - mergeKey(key); - } - - for (var key$1 in child) { - if (!hasOwn(parent, key$1)) { - mergeKey(key$1); - } - } - - function mergeKey(key) { - options[key] = (strats[key] || defaultStrat)(parent[key], child[key], vm); - } - - return options; - } - - function parseOptions(options, args) { - var obj; - - if ( args === void 0 ) args = []; - - try { - - return !options - ? {} - : startsWith(options, '{') - ? JSON.parse(options) - : args.length && !includes(options, ':') - ? (( obj = {}, obj[args[0]] = options, obj )) - : options.split(';').reduce(function (options, option) { - var ref = option.split(/:(.*)/); - var key = ref[0]; - var value = ref[1]; - if (key && !isUndefined(value)) { - options[key.trim()] = value.trim(); - } - return options; - }, {}); - - } catch (e) { - return {}; - } - - } - - function play(el) { - - if (isIFrame(el)) { - call(el, {func: 'playVideo', method: 'play'}); - } - - if (isHTML5(el)) { - try { - el.play().catch(noop); - } catch (e) {} - } - - } - - function pause(el) { - - if (isIFrame(el)) { - call(el, {func: 'pauseVideo', method: 'pause'}); - } - - if (isHTML5(el)) { - el.pause(); - } - - } - - function mute(el) { - - if (isIFrame(el)) { - call(el, {func: 'mute', method: 'setVolume', value: 0}); - } - - if (isHTML5(el)) { - el.muted = true; - } - - } - - function isHTML5(el) { - return el && el.tagName === 'VIDEO'; - } - - function isIFrame(el) { - return el && el.tagName === 'IFRAME' && (isYoutube(el) || isVimeo(el)); - } - - function isYoutube(el) { - return !!el.src.match(/\/\/.*?youtube(-nocookie)?\.[a-z]+\/(watch\?v=[^&\s]+|embed)|youtu\.be\/.*/); - } - - function isVimeo(el) { - return !!el.src.match(/vimeo\.com\/video\/.*/); - } - - function call(el, cmd) { - enableApi(el).then(function () { return post(el, cmd); }); - } - - function post(el, cmd) { - try { - el.contentWindow.postMessage(JSON.stringify(assign({event: 'command'}, cmd)), '*'); - } catch (e) {} - } - - var stateKey$1 = '_ukPlayer'; - var counter = 0; - function enableApi(el) { - - if (el[stateKey$1]) { - return el[stateKey$1]; - } - - var youtube = isYoutube(el); - var vimeo = isVimeo(el); - - var id = ++counter; - var poller; - - return el[stateKey$1] = new Promise$1(function (resolve) { - - youtube && once(el, 'load', function () { - var listener = function () { return post(el, {event: 'listening', id: id}); }; - poller = setInterval(listener, 100); - listener(); - }); - - once(window, 'message', resolve, false, function (ref) { - var data = ref.data; - - - try { - data = JSON.parse(data); - return data && (youtube && data.id === id && data.event === 'onReady' || vimeo && Number(data.player_id) === id); - } catch (e) {} - - }); - - el.src = "" + (el.src) + (includes(el.src, '?') ? '&' : '?') + (youtube ? 'enablejsapi=1' : ("api=1&player_id=" + id)); - - }).then(function () { return clearInterval(poller); }); - } - - function isInView(element, offsetTop, offsetLeft) { - if ( offsetTop === void 0 ) offsetTop = 0; - if ( offsetLeft === void 0 ) offsetLeft = 0; - - - if (!isVisible(element)) { - return false; - } - - return intersectRect.apply(void 0, scrollParents(element).map(function (parent) { - - var ref = offset(getViewport$1(parent)); - var top = ref.top; - var left = ref.left; - var bottom = ref.bottom; - var right = ref.right; - - return { - top: top - offsetTop, - left: left - offsetLeft, - bottom: bottom + offsetTop, - right: right + offsetLeft - }; - }).concat(offset(element))); - } - - function scrollTop(element, top) { - - if (isWindow(element) || isDocument(element)) { - element = getScrollingElement(element); - } else { - element = toNode(element); - } - - element.scrollTop = top; - } - - function scrollIntoView(element, ref) { - if ( ref === void 0 ) ref = {}; - var offsetBy = ref.offset; if ( offsetBy === void 0 ) offsetBy = 0; - - - if (!isVisible(element)) { - return; - } - - var parents = scrollParents(element); - var diff = 0; - return parents.reduce(function (fn, scrollElement, i) { - - var scrollTop = scrollElement.scrollTop; - var scrollHeight = scrollElement.scrollHeight; - var maxScroll = scrollHeight - getViewportClientHeight(scrollElement); - - var top = Math.ceil( - offset(parents[i - 1] || element).top - - offset(getViewport$1(scrollElement)).top - - offsetBy - + diff - + scrollTop - ); - - if (top > maxScroll) { - diff = top - maxScroll; - top = maxScroll; - } else { - diff = 0; - } - - return function () { return scrollTo(scrollElement, top - scrollTop).then(fn); }; - - }, function () { return Promise$1.resolve(); })(); - - function scrollTo(element, top) { - return new Promise$1(function (resolve) { - - var scroll = element.scrollTop; - var duration = getDuration(Math.abs(top)); - var start = Date.now(); - - (function step() { - - var percent = ease(clamp((Date.now() - start) / duration)); - - scrollTop(element, scroll + top * percent); - - // scroll more if we have not reached our destination - if (percent !== 1) { - requestAnimationFrame(step); - } else { - resolve(); - } - - })(); - }); - } - - function getDuration(dist) { - return 40 * Math.pow(dist, .375); - } - - function ease(k) { - return 0.5 * (1 - Math.cos(Math.PI * k)); - } - - } - - function scrolledOver(element, heightOffset) { - if ( heightOffset === void 0 ) heightOffset = 0; - - - if (!isVisible(element)) { - return 0; - } - - var ref = scrollParents(element, /auto|scroll/, true); - var scrollElement = ref[0]; - var scrollHeight = scrollElement.scrollHeight; - var scrollTop = scrollElement.scrollTop; - var clientHeight = getViewportClientHeight(scrollElement); - var viewportTop = offsetPosition(element)[0] - scrollTop - offsetPosition(scrollElement)[0]; - var viewportDist = Math.min(clientHeight, viewportTop + scrollTop); - - var top = viewportTop - viewportDist; - var dist = Math.min( - element.offsetHeight + heightOffset + viewportDist, - scrollHeight - (viewportTop + scrollTop), - scrollHeight - clientHeight - ); - - return clamp(-1 * top / dist); - } - - function scrollParents(element, overflowRe, scrollable) { - if ( overflowRe === void 0 ) overflowRe = /auto|scroll|hidden/; - if ( scrollable === void 0 ) scrollable = false; - - var scrollEl = getScrollingElement(element); - - var ancestors = parents(element).reverse(); - ancestors = ancestors.slice(ancestors.indexOf(scrollEl) + 1); - - var fixedIndex = findIndex(ancestors, function (el) { return css(el, 'position') === 'fixed'; }); - if (~fixedIndex) { - ancestors = ancestors.slice(fixedIndex); - } - - return [scrollEl].concat(ancestors.filter(function (parent) { return overflowRe.test(css(parent, 'overflow')) && (!scrollable || parent.scrollHeight > getViewportClientHeight(parent)); }) - ).reverse(); - } - - function getViewport$1(scrollElement) { - return scrollElement === getScrollingElement(scrollElement) ? window : scrollElement; - } - - // iOS 12 returns as scrollingElement - function getViewportClientHeight(scrollElement) { - return (scrollElement === getScrollingElement(scrollElement) ? document.documentElement : scrollElement).clientHeight; - } - - function getScrollingElement(element) { - var ref = toWindow(element); - var document = ref.document; - return document.scrollingElement || document.documentElement; - } - - var dirs = { - width: ['x', 'left', 'right'], - height: ['y', 'top', 'bottom'] - }; - - function positionAt(element, target, elAttach, targetAttach, elOffset, targetOffset, flip, boundary) { - - elAttach = getPos(elAttach); - targetAttach = getPos(targetAttach); - - var flipped = {element: elAttach, target: targetAttach}; - - if (!element || !target) { - return flipped; - } - - var dim = offset(element); - var targetDim = offset(target); - var position = targetDim; - - moveTo(position, elAttach, dim, -1); - moveTo(position, targetAttach, targetDim, 1); - - elOffset = getOffsets(elOffset, dim.width, dim.height); - targetOffset = getOffsets(targetOffset, targetDim.width, targetDim.height); - - elOffset['x'] += targetOffset['x']; - elOffset['y'] += targetOffset['y']; - - position.left += elOffset['x']; - position.top += elOffset['y']; - - if (flip) { - - var boundaries = scrollParents(element).map(getViewport$1); - - if (boundary && !includes(boundaries, boundary)) { - boundaries.unshift(boundary); - } - - boundaries = boundaries.map(function (el) { return offset(el); }); - - each(dirs, function (ref, prop) { - var dir = ref[0]; - var align = ref[1]; - var alignFlip = ref[2]; - - - if (!(flip === true || includes(flip, dir))) { - return; - } - - boundaries.some(function (boundary) { - - var elemOffset = elAttach[dir] === align - ? -dim[prop] - : elAttach[dir] === alignFlip - ? dim[prop] - : 0; - - var targetOffset = targetAttach[dir] === align - ? targetDim[prop] - : targetAttach[dir] === alignFlip - ? -targetDim[prop] - : 0; - - if (position[align] < boundary[align] || position[align] + dim[prop] > boundary[alignFlip]) { - - var centerOffset = dim[prop] / 2; - var centerTargetOffset = targetAttach[dir] === 'center' ? -targetDim[prop] / 2 : 0; - - return elAttach[dir] === 'center' && ( - apply(centerOffset, centerTargetOffset) - || apply(-centerOffset, -centerTargetOffset) - ) || apply(elemOffset, targetOffset); - - } - - function apply(elemOffset, targetOffset) { - - var newVal = toFloat((position[align] + elemOffset + targetOffset - elOffset[dir] * 2).toFixed(4)); - - if (newVal >= boundary[align] && newVal + dim[prop] <= boundary[alignFlip]) { - position[align] = newVal; - - ['element', 'target'].forEach(function (el) { - flipped[el][dir] = !elemOffset - ? flipped[el][dir] - : flipped[el][dir] === dirs[prop][1] - ? dirs[prop][2] - : dirs[prop][1]; - }); - - return true; - } - - } - - }); - - }); - } - - offset(element, position); - - return flipped; - } - - function moveTo(position, attach, dim, factor) { - each(dirs, function (ref, prop) { - var dir = ref[0]; - var align = ref[1]; - var alignFlip = ref[2]; - - if (attach[dir] === alignFlip) { - position[align] += dim[prop] * factor; - } else if (attach[dir] === 'center') { - position[align] += dim[prop] * factor / 2; - } - }); - } - - function getPos(pos) { - - var x = /left|center|right/; - var y = /top|center|bottom/; - - pos = (pos || '').split(' '); - - if (pos.length === 1) { - pos = x.test(pos[0]) - ? pos.concat('center') - : y.test(pos[0]) - ? ['center'].concat(pos) - : ['center', 'center']; - } - - return { - x: x.test(pos[0]) ? pos[0] : 'center', - y: y.test(pos[1]) ? pos[1] : 'center' - }; - } - - function getOffsets(offsets, width, height) { - - var ref = (offsets || '').split(' '); - var x = ref[0]; - var y = ref[1]; - - return { - x: x ? toFloat(x) * (endsWith(x, '%') ? width / 100 : 1) : 0, - y: y ? toFloat(y) * (endsWith(y, '%') ? height / 100 : 1) : 0 - }; - } - - var util = /*#__PURE__*/Object.freeze({ - __proto__: null, - ajax: ajax, - getImage: getImage, - transition: transition, - Transition: Transition, - animate: animate$1, - Animation: Animation, - attr: attr, - hasAttr: hasAttr, - removeAttr: removeAttr, - data: data, - addClass: addClass, - removeClass: removeClass, - removeClasses: removeClasses, - replaceClass: replaceClass, - hasClass: hasClass, - toggleClass: toggleClass, - dimensions: dimensions, - offset: offset, - position: position, - offsetPosition: offsetPosition, - height: height, - width: width, - boxModelAdjust: boxModelAdjust, - flipPosition: flipPosition, - toPx: toPx, - ready: ready, - empty: empty, - html: html, - prepend: prepend, - append: append, - before: before, - after: after, - remove: remove$1, - wrapAll: wrapAll, - wrapInner: wrapInner, - unwrap: unwrap, - fragment: fragment, - apply: apply$1, - $: $, - $$: $$, - inBrowser: inBrowser, - isIE: isIE, - isRtl: isRtl, - hasTouch: hasTouch, - pointerDown: pointerDown, - pointerMove: pointerMove, - pointerUp: pointerUp, - pointerEnter: pointerEnter, - pointerLeave: pointerLeave, - pointerCancel: pointerCancel, - on: on, - off: off, - once: once, - trigger: trigger, - createEvent: createEvent, - toEventTargets: toEventTargets, - isTouch: isTouch, - getEventPos: getEventPos, - fastdom: fastdom, - isVoidElement: isVoidElement, - isVisible: isVisible, - selInput: selInput, - isInput: isInput, - isFocusable: isFocusable, - parent: parent, - filter: filter$1, - matches: matches, - closest: closest, - within: within, - parents: parents, - children: children, - index: index, - hasOwn: hasOwn, - hyphenate: hyphenate, - camelize: camelize, - ucfirst: ucfirst, - startsWith: startsWith, - endsWith: endsWith, - includes: includes, - findIndex: findIndex, - isArray: isArray, - isFunction: isFunction, - isObject: isObject, - isPlainObject: isPlainObject, - isWindow: isWindow, - isDocument: isDocument, - isNode: isNode, - isElement: isElement, - isBoolean: isBoolean, - isString: isString, - isNumber: isNumber, - isNumeric: isNumeric, - isEmpty: isEmpty, - isUndefined: isUndefined, - toBoolean: toBoolean, - toNumber: toNumber, - toFloat: toFloat, - toArray: toArray, - toNode: toNode, - toNodes: toNodes, - toWindow: toWindow, - toMs: toMs, - isEqual: isEqual, - swap: swap, - assign: assign, - last: last, - each: each, - sortBy: sortBy$1, - uniqueBy: uniqueBy, - clamp: clamp, - noop: noop, - intersectRect: intersectRect, - pointInRect: pointInRect, - Dimensions: Dimensions, - getIndex: getIndex, - memoize: memoize, - MouseTracker: MouseTracker, - mergeOptions: mergeOptions, - parseOptions: parseOptions, - play: play, - pause: pause, - mute: mute, - positionAt: positionAt, - Promise: Promise$1, - Deferred: Deferred, - query: query, - queryAll: queryAll, - find: find, - findAll: findAll, - escape: escape, - css: css, - getCssVar: getCssVar, - propName: propName, - isInView: isInView, - scrollTop: scrollTop, - scrollIntoView: scrollIntoView, - scrolledOver: scrolledOver, - scrollParents: scrollParents, - getViewport: getViewport$1, - getViewportClientHeight: getViewportClientHeight - }); - - function globalAPI (UIkit) { - - var DATA = UIkit.data; - - UIkit.use = function (plugin) { - - if (plugin.installed) { - return; - } - - plugin.call(null, this); - plugin.installed = true; - - return this; - }; - - UIkit.mixin = function (mixin, component) { - component = (isString(component) ? UIkit.component(component) : component) || this; - component.options = mergeOptions(component.options, mixin); - }; - - UIkit.extend = function (options) { - - options = options || {}; - - var Super = this; - var Sub = function UIkitComponent(options) { - this._init(options); - }; - - Sub.prototype = Object.create(Super.prototype); - Sub.prototype.constructor = Sub; - Sub.options = mergeOptions(Super.options, options); - - Sub.super = Super; - Sub.extend = Super.extend; - - return Sub; - }; - - UIkit.update = function (element, e) { - - element = element ? toNode(element) : document.body; - - parents(element).reverse().forEach(function (element) { return update(element[DATA], e); }); - apply$1(element, function (element) { return update(element[DATA], e); }); - - }; - - var container; - Object.defineProperty(UIkit, 'container', { - - get: function() { - return container || document.body; - }, - - set: function(element) { - container = $(element); - } - - }); - - function update(data, e) { - - if (!data) { - return; - } - - for (var name in data) { - if (data[name]._connected) { - data[name]._callUpdate(e); - } - } - - } - } - - function hooksAPI (UIkit) { - - UIkit.prototype._callHook = function (hook) { - var this$1 = this; - - - var handlers = this.$options[hook]; - - if (handlers) { - handlers.forEach(function (handler) { return handler.call(this$1); }); - } - }; - - UIkit.prototype._callConnected = function () { - - if (this._connected) { - return; - } - - this._data = {}; - this._computeds = {}; - - this._initProps(); - - this._callHook('beforeConnect'); - this._connected = true; - - this._initEvents(); - this._initObservers(); - - this._callHook('connected'); - this._callUpdate(); - }; - - UIkit.prototype._callDisconnected = function () { - - if (!this._connected) { - return; - } - - this._callHook('beforeDisconnect'); - this._disconnectObservers(); - this._unbindEvents(); - this._callHook('disconnected'); - - this._connected = false; - delete this._watch; - - }; - - UIkit.prototype._callUpdate = function (e) { - var this$1 = this; - if ( e === void 0 ) e = 'update'; - - - if (!this._connected) { - return; - } - - if (e === 'update' || e === 'resize') { - this._callWatches(); - } - - if (!this.$options.update) { - return; - } - - if (!this._updates) { - this._updates = new Set(); - fastdom.read(function () { - if (this$1._connected) { - runUpdates.call(this$1, this$1._updates); - } - delete this$1._updates; - }); - } - - this._updates.add(e.type || e); - }; - - UIkit.prototype._callWatches = function () { - var this$1 = this; - - - if (this._watch) { - return; - } - - var initial = !hasOwn(this, '_watch'); - - this._watch = fastdom.read(function () { - if (this$1._connected) { - runWatches.call(this$1, initial); - } - this$1._watch = null; - - }); - - }; - - function runUpdates(types) { - var this$1 = this; - - - var updates = this.$options.update; - - var loop = function ( i ) { - var ref = updates[i]; - var read = ref.read; - var write = ref.write; - var events = ref.events; - - if (!types.has('update') && (!events || !events.some(function (type) { return types.has(type); }))) { - return; - } - - var result = (void 0); - if (read) { - - result = read.call(this$1, this$1._data, types); - - if (result && isPlainObject(result)) { - assign(this$1._data, result); - } - } - - if (write && result !== false) { - fastdom.write(function () { return write.call(this$1, this$1._data, types); }); - } - - }; - - for (var i = 0; i < updates.length; i++) loop( i ); - } - - function runWatches(initial) { - - var ref = this; - var computed = ref.$options.computed; - var _computeds = ref._computeds; - - for (var key in computed) { - - var hasPrev = hasOwn(_computeds, key); - var prev = _computeds[key]; - - delete _computeds[key]; - - var ref$1 = computed[key]; - var watch = ref$1.watch; - var immediate = ref$1.immediate; - if (watch && ( - initial && immediate - || hasPrev && !isEqual(prev, this[key]) - )) { - watch.call(this, this[key], prev); - } - - } - } - } - - function stateAPI (UIkit) { - - var uid = 0; - - UIkit.prototype._init = function (options) { - - options = options || {}; - options.data = normalizeData(options, this.constructor.options); - - this.$options = mergeOptions(this.constructor.options, options, this); - this.$el = null; - this.$props = {}; - - this._uid = uid++; - this._initData(); - this._initMethods(); - this._initComputeds(); - this._callHook('created'); - - if (options.el) { - this.$mount(options.el); - } - }; - - UIkit.prototype._initData = function () { - - var ref = this.$options; - var data = ref.data; if ( data === void 0 ) data = {}; - - for (var key in data) { - this.$props[key] = this[key] = data[key]; - } - }; - - UIkit.prototype._initMethods = function () { - - var ref = this.$options; - var methods = ref.methods; - - if (methods) { - for (var key in methods) { - this[key] = methods[key].bind(this); - } - } - }; - - UIkit.prototype._initComputeds = function () { - - var ref = this.$options; - var computed = ref.computed; - - this._computeds = {}; - - if (computed) { - for (var key in computed) { - registerComputed(this, key, computed[key]); - } - } - }; - - UIkit.prototype._initProps = function (props) { - - var key; - - props = props || getProps(this.$options, this.$name); - - for (key in props) { - if (!isUndefined(props[key])) { - this.$props[key] = props[key]; - } - } - - var exclude = [this.$options.computed, this.$options.methods]; - for (key in this.$props) { - if (key in props && notIn(exclude, key)) { - this[key] = this.$props[key]; - } - } - }; - - UIkit.prototype._initEvents = function () { - var this$1 = this; - - - this._events = []; - - var ref = this.$options; - var events = ref.events; - - if (events) { - - events.forEach(function (event) { - - if (!hasOwn(event, 'handler')) { - for (var key in event) { - registerEvent(this$1, event[key], key); - } - } else { - registerEvent(this$1, event); - } - - }); - } - }; - - UIkit.prototype._unbindEvents = function () { - this._events.forEach(function (unbind) { return unbind(); }); - delete this._events; - }; - - UIkit.prototype._initObservers = function () { - this._observers = [ - initChildListObserver(this), - initPropsObserver(this) - ]; - }; - - UIkit.prototype._disconnectObservers = function () { - this._observers.forEach(function (observer) { return observer && observer.disconnect(); } - ); - }; - - function getProps(opts, name) { - - var data$1 = {}; - var args = opts.args; if ( args === void 0 ) args = []; - var props = opts.props; if ( props === void 0 ) props = {}; - var el = opts.el; - - if (!props) { - return data$1; - } - - for (var key in props) { - var prop = hyphenate(key); - var value = data(el, prop); - - if (isUndefined(value)) { - continue; - } - - value = props[key] === Boolean && value === '' - ? true - : coerce(props[key], value); - - if (prop === 'target' && (!value || startsWith(value, '_'))) { - continue; - } - - data$1[key] = value; - } - - var options = parseOptions(data(el, name), args); - - for (var key$1 in options) { - var prop$1 = camelize(key$1); - if (props[prop$1] !== undefined) { - data$1[prop$1] = coerce(props[prop$1], options[key$1]); - } - } - - return data$1; - } - - function registerComputed(component, key, cb) { - Object.defineProperty(component, key, { - - enumerable: true, - - get: function() { - - var _computeds = component._computeds; - var $props = component.$props; - var $el = component.$el; - - if (!hasOwn(_computeds, key)) { - _computeds[key] = (cb.get || cb).call(component, $props, $el); - } - - return _computeds[key]; - }, - - set: function(value) { - - var _computeds = component._computeds; - - _computeds[key] = cb.set ? cb.set.call(component, value) : value; - - if (isUndefined(_computeds[key])) { - delete _computeds[key]; - } - } - - }); - } - - function registerEvent(component, event, key) { - - if (!isPlainObject(event)) { - event = ({name: key, handler: event}); - } - - var name = event.name; - var el = event.el; - var handler = event.handler; - var capture = event.capture; - var passive = event.passive; - var delegate = event.delegate; - var filter = event.filter; - var self = event.self; - el = isFunction(el) - ? el.call(component) - : el || component.$el; - - if (isArray(el)) { - el.forEach(function (el) { return registerEvent(component, assign({}, event, {el: el}), key); }); - return; - } - - if (!el || filter && !filter.call(component)) { - return; - } - - component._events.push( - on( - el, - name, - !delegate - ? null - : isString(delegate) - ? delegate - : delegate.call(component), - isString(handler) ? component[handler] : handler.bind(component), - {passive: passive, capture: capture, self: self} - ) - ); - - } - - function notIn(options, key) { - return options.every(function (arr) { return !arr || !hasOwn(arr, key); }); - } - - function coerce(type, value) { - - if (type === Boolean) { - return toBoolean(value); - } else if (type === Number) { - return toNumber(value); - } else if (type === 'list') { - return toList(value); - } - - return type ? type(value) : value; - } - - function toList(value) { - return isArray(value) - ? value - : isString(value) - ? value.split(/,(?![^(]*\))/).map(function (value) { return isNumeric(value) - ? toNumber(value) - : toBoolean(value.trim()); }) - : [value]; - } - - function normalizeData(ref, ref$1) { - var data = ref.data; - var args = ref$1.args; - var props = ref$1.props; if ( props === void 0 ) props = {}; - - data = isArray(data) - ? !isEmpty(args) - ? data.slice(0, args.length).reduce(function (data, value, index) { - if (isPlainObject(value)) { - assign(data, value); - } else { - data[args[index]] = value; - } - return data; - }, {}) - : undefined - : data; - - if (data) { - for (var key in data) { - if (isUndefined(data[key])) { - delete data[key]; - } else { - data[key] = props[key] ? coerce(props[key], data[key]) : data[key]; - } - } - } - - return data; - } - - function initChildListObserver(component) { - var ref = component.$options; - var el = ref.el; - - var observer = new MutationObserver(function () { return component.$emit(); }); - observer.observe(el, { - childList: true, - subtree: true - }); - - return observer; - } - - function initPropsObserver(component) { - - var $name = component.$name; - var $options = component.$options; - var $props = component.$props; - var attrs = $options.attrs; - var props = $options.props; - var el = $options.el; - - if (!props || attrs === false) { - return; - } - - var attributes = isArray(attrs) ? attrs : Object.keys(props); - var filter = attributes.map(function (key) { return hyphenate(key); }).concat($name); - - var observer = new MutationObserver(function (records) { - var data = getProps($options, $name); - if (records.some(function (ref) { - var attributeName = ref.attributeName; - - var prop = attributeName.replace('data-', ''); - return (prop === $name ? attributes : [camelize(prop), camelize(attributeName)]).some(function (prop) { return !isUndefined(data[prop]) && data[prop] !== $props[prop]; } - ); - })) { - component.$reset(); - } - }); - - observer.observe(el, { - attributes: true, - attributeFilter: filter.concat(filter.map(function (key) { return ("data-" + key); })) - }); - - return observer; - } - } - - function instanceAPI (UIkit) { - - var DATA = UIkit.data; - - UIkit.prototype.$create = function (component, element, data) { - return UIkit[component](element, data); - }; - - UIkit.prototype.$mount = function (el) { - - var ref = this.$options; - var name = ref.name; - - if (!el[DATA]) { - el[DATA] = {}; - } - - if (el[DATA][name]) { - return; - } - - el[DATA][name] = this; - - this.$el = this.$options.el = this.$options.el || el; - - if (within(el, document)) { - this._callConnected(); - } - }; - - UIkit.prototype.$reset = function () { - this._callDisconnected(); - this._callConnected(); - }; - - UIkit.prototype.$destroy = function (removeEl) { - if ( removeEl === void 0 ) removeEl = false; - - - var ref = this.$options; - var el = ref.el; - var name = ref.name; - - if (el) { - this._callDisconnected(); - } - - this._callHook('destroy'); - - if (!el || !el[DATA]) { - return; - } - - delete el[DATA][name]; - - if (!isEmpty(el[DATA])) { - delete el[DATA]; - } - - if (removeEl) { - remove$1(this.$el); - } - }; - - UIkit.prototype.$emit = function (e) { - this._callUpdate(e); - }; - - UIkit.prototype.$update = function (element, e) { - if ( element === void 0 ) element = this.$el; - - UIkit.update(element, e); - }; - - UIkit.prototype.$getComponent = UIkit.getComponent; - - var componentName = memoize(function (name) { return UIkit.prefix + hyphenate(name); }); - Object.defineProperties(UIkit.prototype, { - - $container: Object.getOwnPropertyDescriptor(UIkit, 'container'), - - $name: { - - get: function() { - return componentName(this.$options.name); - } - - } - - }); - - } - - function componentAPI (UIkit) { - - var DATA = UIkit.data; - - var components = {}; - - UIkit.component = function (name, options) { - - var id = hyphenate(name); - - name = camelize(id); - - if (!options) { - - if (isPlainObject(components[name])) { - components[name] = UIkit.extend(components[name]); - } - - return components[name]; - - } - - UIkit[name] = function (element, data) { - var i = arguments.length, argsArray = Array(i); - while ( i-- ) argsArray[i] = arguments[i]; - - - var component = UIkit.component(name); - - return component.options.functional - ? new component({data: isPlainObject(element) ? element : [].concat( argsArray )}) - : !element ? init(element) : $$(element).map(init)[0]; - - function init(element) { - - var instance = UIkit.getComponent(element, name); - - if (instance) { - if (!data) { - return instance; - } else { - instance.$destroy(); - } - } - - return new component({el: element, data: data}); - - } - - }; - - var opt = isPlainObject(options) ? assign({}, options) : options.options; - - opt.name = name; - - if (opt.install) { - opt.install(UIkit, opt, name); - } - - if (UIkit._initialized && !opt.functional) { - fastdom.read(function () { return UIkit[name](("[uk-" + id + "],[data-uk-" + id + "]")); }); - } - - return components[name] = isPlainObject(options) ? opt : options; - }; - - UIkit.getComponents = function (element) { return element && element[DATA] || {}; }; - UIkit.getComponent = function (element, name) { return UIkit.getComponents(element)[name]; }; - - UIkit.connect = function (node) { - - if (node[DATA]) { - for (var name in node[DATA]) { - node[DATA][name]._callConnected(); - } - } - - for (var i = 0; i < node.attributes.length; i++) { - - var name$1 = getComponentName(node.attributes[i].name); - - if (name$1 && name$1 in components) { - UIkit[name$1](node); - } - - } - - }; - - UIkit.disconnect = function (node) { - for (var name in node[DATA]) { - node[DATA][name]._callDisconnected(); - } - }; - - } - - var getComponentName = memoize(function (attribute) { - return startsWith(attribute, 'uk-') || startsWith(attribute, 'data-uk-') - ? camelize(attribute.replace('data-uk-', '').replace('uk-', '')) - : false; - }); - - var UIkit = function (options) { - this._init(options); - }; - - UIkit.util = util; - UIkit.data = '__uikit__'; - UIkit.prefix = 'uk-'; - UIkit.options = {}; - UIkit.version = '3.7.0'; - - globalAPI(UIkit); - hooksAPI(UIkit); - stateAPI(UIkit); - componentAPI(UIkit); - instanceAPI(UIkit); - - function Core (UIkit) { - - if (!inBrowser) { - return; - } - - // throttle 'resize' - var pendingResize; - var handleResize = function () { - if (pendingResize) { - return; - } - pendingResize = true; - fastdom.write(function () { return pendingResize = false; }); - UIkit.update(null, 'resize'); - }; - - on(window, 'load resize', handleResize); - on(document, 'loadedmetadata load', handleResize, true); - - if ('ResizeObserver' in window) { - (new ResizeObserver(handleResize)).observe(document.documentElement); - } - - // throttle `scroll` event (Safari triggers multiple `scroll` events per frame) - var pending; - on(window, 'scroll', function (e) { - - if (pending) { - return; - } - pending = true; - fastdom.write(function () { return pending = false; }); - - UIkit.update(null, e.type); - - }, {passive: true, capture: true}); - - var started = 0; - on(document, 'animationstart', function (ref) { - var target = ref.target; - - if ((css(target, 'animationName') || '').match(/^uk-.*(left|right)/)) { - - started++; - css(document.documentElement, 'overflowX', 'hidden'); - setTimeout(function () { - if (!--started) { - css(document.documentElement, 'overflowX', ''); - } - }, toMs(css(target, 'animationDuration')) + 100); - } - }, true); - - on(document, pointerDown, function (e) { - - if (!isTouch(e)) { - return; - } - - // Handle Swipe Gesture - var pos = getEventPos(e); - var target = 'tagName' in e.target ? e.target : parent(e.target); - once(document, (pointerUp + " " + pointerCancel + " scroll"), function (e) { - - var ref = getEventPos(e); - var x = ref.x; - var y = ref.y; - - // swipe - if (e.type !== 'scroll' && target && x && Math.abs(pos.x - x) > 100 || y && Math.abs(pos.y - y) > 100) { - - setTimeout(function () { - trigger(target, 'swipe'); - trigger(target, ("swipe" + (swipeDirection(pos.x, pos.y, x, y)))); - }); - - } - - }); - - }, {passive: true}); - - } - - function swipeDirection(x1, y1, x2, y2) { - return Math.abs(x1 - x2) >= Math.abs(y1 - y2) - ? x1 - x2 > 0 - ? 'Left' - : 'Right' - : y1 - y2 > 0 - ? 'Up' - : 'Down'; - } - - function boot (UIkit) { - - var connect = UIkit.connect; - var disconnect = UIkit.disconnect; - - if (!inBrowser || !window.MutationObserver) { - return; - } - - fastdom.read(function () { - - if (document.body) { - apply$1(document.body, connect); - } - - new MutationObserver(function (records) { return records.forEach(applyChildListMutation); } - ).observe(document, { - childList: true, - subtree: true - }); - - new MutationObserver(function (records) { return records.forEach(applyAttributeMutation); } - ).observe(document, { - attributes: true, - subtree: true - }); - - UIkit._initialized = true; - }); - - function applyChildListMutation(ref) { - var addedNodes = ref.addedNodes; - var removedNodes = ref.removedNodes; - - for (var i = 0; i < addedNodes.length; i++) { - apply$1(addedNodes[i], connect); - } - - for (var i$1 = 0; i$1 < removedNodes.length; i$1++) { - apply$1(removedNodes[i$1], disconnect); - } - } - - function applyAttributeMutation(ref) { - var target = ref.target; - var attributeName = ref.attributeName; - - - var name = getComponentName(attributeName); - - if (!name || !(name in UIkit)) { - return; - } - - if (hasAttr(target, attributeName)) { - UIkit[name](target); - return; - } - - var component = UIkit.getComponent(target, name); - - if (component) { - component.$destroy(); - } - - } - - } - - var Class = { - - connected: function() { - !hasClass(this.$el, this.$name) && addClass(this.$el, this.$name); - } - - }; - - var Togglable = { - - props: { - cls: Boolean, - animation: 'list', - duration: Number, - origin: String, - transition: String - }, - - data: { - cls: false, - animation: [false], - duration: 200, - origin: false, - transition: 'linear', - clsEnter: 'uk-togglabe-enter', - clsLeave: 'uk-togglabe-leave', - - initProps: { - overflow: '', - height: '', - paddingTop: '', - paddingBottom: '', - marginTop: '', - marginBottom: '' - }, - - hideProps: { - overflow: 'hidden', - height: 0, - paddingTop: 0, - paddingBottom: 0, - marginTop: 0, - marginBottom: 0 - } - - }, - - computed: { - - hasAnimation: function(ref) { - var animation = ref.animation; - - return !!animation[0]; - }, - - hasTransition: function(ref) { - var animation = ref.animation; - - return this.hasAnimation && animation[0] === true; - } - - }, - - methods: { - - toggleElement: function(targets, toggle, animate) { - var this$1 = this; - - return new Promise$1(function (resolve) { return Promise$1.all(toNodes(targets).map(function (el) { - - var show = isBoolean(toggle) ? toggle : !this$1.isToggled(el); - - if (!trigger(el, ("before" + (show ? 'show' : 'hide')), [this$1])) { - return Promise$1.reject(); - } - - var promise = ( - isFunction(animate) - ? animate - : animate === false || !this$1.hasAnimation - ? this$1._toggle - : this$1.hasTransition - ? toggleHeight(this$1) - : toggleAnimation(this$1) - )(el, show); - - var cls = show ? this$1.clsEnter : this$1.clsLeave; - - addClass(el, cls); - - trigger(el, show ? 'show' : 'hide', [this$1]); - - var done = function () { - removeClass(el, cls); - trigger(el, show ? 'shown' : 'hidden', [this$1]); - this$1.$update(el); - }; - - return promise ? promise.then(done, function () { - removeClass(el, cls); - return Promise$1.reject(); - }) : done(); - - })).then(resolve, noop); } - ); - }, - - isToggled: function(el) { - if ( el === void 0 ) el = this.$el; - - return hasClass(el, this.clsEnter) - ? true - : hasClass(el, this.clsLeave) - ? false - : this.cls - ? hasClass(el, this.cls.split(' ')[0]) - : !hasAttr(el, 'hidden'); - }, - - _toggle: function(el, toggled) { - - if (!el) { - return; - } - - toggled = Boolean(toggled); - - var changed; - if (this.cls) { - changed = includes(this.cls, ' ') || toggled !== hasClass(el, this.cls); - changed && toggleClass(el, this.cls, includes(this.cls, ' ') ? undefined : toggled); - } else { - changed = toggled === el.hidden; - changed && (el.hidden = !toggled); - } - - $$('[autofocus]', el).some(function (el) { return isVisible(el) ? el.focus() || true : el.blur(); }); - - if (changed) { - trigger(el, 'toggled', [toggled, this]); - this.$update(el); - } - } - - } - - }; - - function toggleHeight(ref) { - var isToggled = ref.isToggled; - var duration = ref.duration; - var initProps = ref.initProps; - var hideProps = ref.hideProps; - var transition = ref.transition; - var _toggle = ref._toggle; - - return function (el, show) { - - var inProgress = Transition.inProgress(el); - var inner = el.hasChildNodes ? toFloat(css(el.firstElementChild, 'marginTop')) + toFloat(css(el.lastElementChild, 'marginBottom')) : 0; - var currentHeight = isVisible(el) ? height(el) + (inProgress ? 0 : inner) : 0; - - Transition.cancel(el); - - if (!isToggled(el)) { - _toggle(el, true); - } - - height(el, ''); - - // Update child components first - fastdom.flush(); - - var endHeight = height(el) + (inProgress ? 0 : inner); - height(el, currentHeight); - - return (show - ? Transition.start(el, assign({}, initProps, {overflow: 'hidden', height: endHeight}), Math.round(duration * (1 - currentHeight / endHeight)), transition) - : Transition.start(el, hideProps, Math.round(duration * (currentHeight / endHeight)), transition).then(function () { return _toggle(el, false); }) - ).then(function () { return css(el, initProps); }); - - }; - } - - function toggleAnimation(cmp) { - return function (el, show) { - - Animation.cancel(el); - - var animation = cmp.animation; - var duration = cmp.duration; - var _toggle = cmp._toggle; - - if (show) { - _toggle(el, true); - return Animation.in(el, animation[0], duration, cmp.origin); - } - - return Animation.out(el, animation[1] || animation[0], duration, cmp.origin).then(function () { return _toggle(el, false); }); - }; - } - - var Accordion = { - - mixins: [Class, Togglable], - - props: { - targets: String, - active: null, - collapsible: Boolean, - multiple: Boolean, - toggle: String, - content: String, - transition: String, - offset: Number - }, - - data: { - targets: '> *', - active: false, - animation: [true], - collapsible: true, - multiple: false, - clsOpen: 'uk-open', - toggle: '> .uk-accordion-title', - content: '> .uk-accordion-content', - transition: 'ease', - offset: 0 - }, - - computed: { - - items: { - - get: function(ref, $el) { - var targets = ref.targets; - - return $$(targets, $el); - }, - - watch: function(items, prev) { - var this$1 = this; - - - items.forEach(function (el) { return hide($(this$1.content, el), !hasClass(el, this$1.clsOpen)); }); - - if (prev || hasClass(items, this.clsOpen)) { - return; - } - - var active = this.active !== false && items[Number(this.active)] - || !this.collapsible && items[0]; - - if (active) { - this.toggle(active, false); - } - - }, - - immediate: true - - }, - - toggles: function(ref) { - var toggle = ref.toggle; - - return this.items.map(function (item) { return $(toggle, item); }); - } - - }, - - events: [ - - { - - name: 'click', - - delegate: function() { - return ((this.targets) + " " + (this.$props.toggle)); - }, - - handler: function(e) { - e.preventDefault(); - this.toggle(index(this.toggles, e.current)); - } - - } - - ], - - methods: { - - toggle: function(item, animate) { - var this$1 = this; - - - var items = [this.items[getIndex(item, this.items)]]; - var activeItems = filter$1(this.items, ("." + (this.clsOpen))); - - if (!this.multiple && !includes(activeItems, items[0])) { - items = items.concat(activeItems); - } - - if (!this.collapsible && activeItems.length < 2 && !filter$1(items, (":not(." + (this.clsOpen) + ")")).length) { - return; - } - - items.forEach(function (el) { return this$1.toggleElement(el, !hasClass(el, this$1.clsOpen), function (el, show) { - - toggleClass(el, this$1.clsOpen, show); - attr($(this$1.$props.toggle, el), 'aria-expanded', show); - - var content = $(("" + (el._wrapper ? '> * ' : '') + (this$1.content)), el); - - if (animate === false || !this$1.hasTransition) { - hide(content, !show); - return; - } - - if (!el._wrapper) { - el._wrapper = wrapAll(content, ("")); - } - - hide(content, false); - return toggleHeight(this$1)(el._wrapper, show).then(function () { - hide(content, !show); - delete el._wrapper; - unwrap(content); - - if (show) { - var toggle = $(this$1.$props.toggle, el); - if (!isInView(toggle)) { - scrollIntoView(toggle, {offset: this$1.offset}); - } - } - }); - }); }); - } - - } - - }; - - function hide(el, hide) { - el && (el.hidden = hide); - } - - var alert = { - - mixins: [Class, Togglable], - - args: 'animation', - - props: { - close: String - }, - - data: { - animation: [true], - selClose: '.uk-alert-close', - duration: 150, - hideProps: assign({opacity: 0}, Togglable.data.hideProps) - }, - - events: [ - - { - - name: 'click', - - delegate: function() { - return this.selClose; - }, - - handler: function(e) { - e.preventDefault(); - this.close(); - } - - } - - ], - - methods: { - - close: function() { - var this$1 = this; - - this.toggleElement(this.$el).then(function () { return this$1.$destroy(true); }); - } - - } - - }; - - var Video = { - - args: 'autoplay', - - props: { - automute: Boolean, - autoplay: Boolean - }, - - data: { - automute: false, - autoplay: true - }, - - computed: { - - inView: function(ref) { - var autoplay = ref.autoplay; - - return autoplay === 'inview'; - } - - }, - - connected: function() { - - if (this.inView && !hasAttr(this.$el, 'preload')) { - this.$el.preload = 'none'; - } - - if (this.automute) { - mute(this.$el); - } - - }, - - update: { - - read: function() { - return { - visible: isVisible(this.$el) && css(this.$el, 'visibility') !== 'hidden', - inView: this.inView && isInView(this.$el) - }; - }, - - write: function(ref) { - var visible = ref.visible; - var inView = ref.inView; - - - if (!visible || this.inView && !inView) { - pause(this.$el); - } else if (this.autoplay === true || this.inView && inView) { - play(this.$el); - } - - }, - - events: ['resize', 'scroll'] - - } - - }; - - var cover = { - - mixins: [Class, Video], - - props: { - width: Number, - height: Number - }, - - data: { - automute: true - }, - - update: { - - read: function() { - - var el = this.$el; - var ref = getPositionedParent(el) || parent(el); - var height = ref.offsetHeight; - var width = ref.offsetWidth; - var dim = Dimensions.cover( - { - width: this.width || el.naturalWidth || el.videoWidth || el.clientWidth, - height: this.height || el.naturalHeight || el.videoHeight || el.clientHeight - }, - { - width: width + (width % 2 ? 1 : 0), - height: height + (height % 2 ? 1 : 0) - } - ); - - if (!dim.width || !dim.height) { - return false; - } - - return dim; - }, - - write: function(ref) { - var height = ref.height; - var width = ref.width; - - css(this.$el, {height: height, width: width}); - }, - - events: ['resize'] - - } - - }; - - function getPositionedParent(el) { - while ((el = parent(el))) { - if (css(el, 'position') !== 'static') { - return el; - } - } - } - - var Container = { - - props: { - container: Boolean - }, - - data: { - container: true - }, - - computed: { - - container: function(ref) { - var container = ref.container; - - return container === true && this.$container || container && $(container); - } - - } - - }; - - var Position = { - - props: { - pos: String, - offset: null, - flip: Boolean, - clsPos: String - }, - - data: { - pos: ("bottom-" + (!isRtl ? 'left' : 'right')), - flip: true, - offset: false, - clsPos: '' - }, - - computed: { - - pos: function(ref) { - var pos = ref.pos; - - return (pos + (!includes(pos, '-') ? '-center' : '')).split('-'); - }, - - dir: function() { - return this.pos[0]; - }, - - align: function() { - return this.pos[1]; - } - - }, - - methods: { - - positionAt: function(element, target, boundary) { - - removeClasses(element, ((this.clsPos) + "-(top|bottom|left|right)(-[a-z]+)?")); - - var ref = this; - var offset$1 = ref.offset; - var axis = this.getAxis(); - - if (!isNumeric(offset$1)) { - var node = $(offset$1); - offset$1 = node - ? offset(node)[axis === 'x' ? 'left' : 'top'] - offset(target)[axis === 'x' ? 'right' : 'bottom'] - : 0; - } - - var ref$1 = positionAt( - element, - target, - axis === 'x' ? ((flipPosition(this.dir)) + " " + (this.align)) : ((this.align) + " " + (flipPosition(this.dir))), - axis === 'x' ? ((this.dir) + " " + (this.align)) : ((this.align) + " " + (this.dir)), - axis === 'x' ? ("" + (this.dir === 'left' ? -offset$1 : offset$1)) : (" " + (this.dir === 'top' ? -offset$1 : offset$1)), - null, - this.flip, - boundary - ).target; - var x = ref$1.x; - var y = ref$1.y; - - this.dir = axis === 'x' ? x : y; - this.align = axis === 'x' ? y : x; - - toggleClass(element, ((this.clsPos) + "-" + (this.dir) + "-" + (this.align)), this.offset === false); - - }, - - getAxis: function() { - return this.dir === 'top' || this.dir === 'bottom' ? 'y' : 'x'; - } - - } - - }; - - var active$1; - - var drop = { - - mixins: [Container, Position, Togglable], - - args: 'pos', - - props: { - mode: 'list', - toggle: Boolean, - boundary: Boolean, - boundaryAlign: Boolean, - delayShow: Number, - delayHide: Number, - clsDrop: String - }, - - data: { - mode: ['click', 'hover'], - toggle: '- *', - boundary: true, - boundaryAlign: false, - delayShow: 0, - delayHide: 800, - clsDrop: false, - animation: ['uk-animation-fade'], - cls: 'uk-open', - container: false - }, - - computed: { - - boundary: function(ref, $el) { - var boundary = ref.boundary; - - return boundary === true ? window : query(boundary, $el); - }, - - clsDrop: function(ref) { - var clsDrop = ref.clsDrop; - - return clsDrop || ("uk-" + (this.$options.name)); - }, - - clsPos: function() { - return this.clsDrop; - } - - }, - - created: function() { - this.tracker = new MouseTracker(); - }, - - connected: function() { - - addClass(this.$el, this.clsDrop); - - if (this.toggle && !this.target) { - this.target = this.$create('toggle', query(this.toggle, this.$el), { - target: this.$el, - mode: this.mode - }); - } - - }, - - disconnected: function() { - if (this.isActive()) { - active$1 = null; - } - }, - - events: [ - - { - - name: 'click', - - delegate: function() { - return ("." + (this.clsDrop) + "-close"); - }, - - handler: function(e) { - e.preventDefault(); - this.hide(false); - } - - }, - - { - - name: 'click', - - delegate: function() { - return 'a[href^="#"]'; - }, - - handler: function(ref) { - var defaultPrevented = ref.defaultPrevented; - var hash = ref.current.hash; - - if (!defaultPrevented && hash && !within(hash, this.$el)) { - this.hide(false); - } - } - - }, - - { - - name: 'beforescroll', - - handler: function() { - this.hide(false); - } - - }, - - { - - name: 'toggle', - - self: true, - - handler: function(e, toggle) { - - e.preventDefault(); - - if (this.isToggled()) { - this.hide(false); - } else { - this.show(toggle.$el, false); - } - } - - }, - - { - - name: 'toggleshow', - - self: true, - - handler: function(e, toggle) { - e.preventDefault(); - this.show(toggle.$el); - } - - }, - - { - - name: 'togglehide', - - self: true, - - handler: function(e) { - e.preventDefault(); - this.hide(); - } - - }, - - { - - name: (pointerEnter + " focusin"), - - filter: function() { - return includes(this.mode, 'hover'); - }, - - handler: function(e) { - if (!isTouch(e)) { - this.clearTimers(); - } - } - - }, - - { - - name: (pointerLeave + " focusout"), - - filter: function() { - return includes(this.mode, 'hover'); - }, - - handler: function(e) { - if (!isTouch(e) && e.relatedTarget) { - this.hide(); - } - } - - }, - - { - - name: 'toggled', - - self: true, - - handler: function(e, toggled) { - - if (!toggled) { - return; - } - - this.clearTimers(); - this.position(); - } - - }, - - { - - name: 'show', - - self: true, - - handler: function() { - var this$1 = this; - - - active$1 = this; - - this.tracker.init(); - - once(this.$el, 'hide', on(document, pointerDown, function (ref) { - var target = ref.target; - - return !within(target, this$1.$el) && once(document, (pointerUp + " " + pointerCancel + " scroll"), function (ref) { - var defaultPrevented = ref.defaultPrevented; - var type = ref.type; - var newTarget = ref.target; - - if (!defaultPrevented && type === pointerUp && target === newTarget && !(this$1.target && within(target, this$1.target))) { - this$1.hide(false); - } - }, true); - } - ), {self: true}); - - once(this.$el, 'hide', on(document, 'keydown', function (e) { - if (e.keyCode === 27) { - this$1.hide(false); - } - }), {self: true}); - - } - - }, - - { - - name: 'beforehide', - - self: true, - - handler: function() { - this.clearTimers(); - } - - }, - - { - - name: 'hide', - - handler: function(ref) { - var target = ref.target; - - - if (this.$el !== target) { - active$1 = active$1 === null && within(target, this.$el) && this.isToggled() ? this : active$1; - return; - } - - active$1 = this.isActive() ? null : active$1; - this.tracker.cancel(); - } - - } - - ], - - update: { - - write: function() { - - if (this.isToggled() && !hasClass(this.$el, this.clsEnter)) { - this.position(); - } - - }, - - events: ['resize'] - - }, - - methods: { - - show: function(target, delay) { - var this$1 = this; - if ( target === void 0 ) target = this.target; - if ( delay === void 0 ) delay = true; - - - if (this.isToggled() && target && this.target && target !== this.target) { - this.hide(false); - } - - this.target = target; - - this.clearTimers(); - - if (this.isActive()) { - return; - } - - if (active$1) { - - if (delay && active$1.isDelaying) { - this.showTimer = setTimeout(this.show, 10); - return; - } - - var prev; - while (active$1 && prev !== active$1 && !within(this.$el, active$1.$el)) { - prev = active$1; - active$1.hide(false); - } - - } - - if (this.container && parent(this.$el) !== this.container) { - append(this.container, this.$el); - } - - this.showTimer = setTimeout(function () { return this$1.toggleElement(this$1.$el, true); }, delay && this.delayShow || 0); - - }, - - hide: function(delay) { - var this$1 = this; - if ( delay === void 0 ) delay = true; - - - var hide = function () { return this$1.toggleElement(this$1.$el, false, false); }; - - this.clearTimers(); - - this.isDelaying = getPositionedElements(this.$el).some(function (el) { return this$1.tracker.movesTo(el); }); - - if (delay && this.isDelaying) { - this.hideTimer = setTimeout(this.hide, 50); - } else if (delay && this.delayHide) { - this.hideTimer = setTimeout(hide, this.delayHide); - } else { - hide(); - } - }, - - clearTimers: function() { - clearTimeout(this.showTimer); - clearTimeout(this.hideTimer); - this.showTimer = null; - this.hideTimer = null; - this.isDelaying = false; - }, - - isActive: function() { - return active$1 === this; - }, - - position: function() { - - removeClass(this.$el, ((this.clsDrop) + "-stack")); - toggleClass(this.$el, ((this.clsDrop) + "-boundary"), this.boundaryAlign); - - var boundary = offset(this.boundary); - var alignTo = this.boundaryAlign ? boundary : offset(this.target); - - if (this.align === 'justify') { - var prop = this.getAxis() === 'y' ? 'width' : 'height'; - css(this.$el, prop, alignTo[prop]); - } else if (this.boundary && this.$el.offsetWidth > Math.max(boundary.right - alignTo.left, alignTo.right - boundary.left)) { - addClass(this.$el, ((this.clsDrop) + "-stack")); - } - - this.positionAt(this.$el, this.boundaryAlign ? this.boundary : this.target, this.boundary); - - } - - } - - }; - - function getPositionedElements(el) { - var result = []; - apply$1(el, function (el) { return css(el, 'position') !== 'static' && result.push(el); }); - return result; - } - - var formCustom = { - - mixins: [Class], - - args: 'target', - - props: { - target: Boolean - }, - - data: { - target: false - }, - - computed: { - - input: function(_, $el) { - return $(selInput, $el); - }, - - state: function() { - return this.input.nextElementSibling; - }, - - target: function(ref, $el) { - var target = ref.target; - - return target && (target === true - && parent(this.input) === $el - && this.input.nextElementSibling - || query(target, $el)); - } - - }, - - update: function() { - - var ref = this; - var target = ref.target; - var input = ref.input; - - if (!target) { - return; - } - - var option; - var prop = isInput(target) ? 'value' : 'textContent'; - var prev = target[prop]; - var value = input.files && input.files[0] - ? input.files[0].name - : matches(input, 'select') && (option = $$('option', input).filter(function (el) { return el.selected; })[0]) // eslint-disable-line prefer-destructuring - ? option.textContent - : input.value; - - if (prev !== value) { - target[prop] = value; - } - - }, - - events: [ - - { - name: 'change', - - handler: function() { - this.$update(); - } - }, - - { - name: 'reset', - - el: function() { - return closest(this.$el, 'form'); - }, - - handler: function() { - this.$update(); - } - } - - ] - - }; - - // Deprecated - var gif = { - - update: { - - read: function(data) { - - var inview = isInView(this.$el); - - if (!inview || data.isInView === inview) { - return false; - } - - data.isInView = inview; - }, - - write: function() { - this.$el.src = '' + this.$el.src; // force self-assign - }, - - events: ['scroll', 'resize'] - } - - }; - - var Margin = { - - props: { - margin: String, - firstColumn: Boolean - }, - - data: { - margin: 'uk-margin-small-top', - firstColumn: 'uk-first-column' - }, - - update: { - - read: function() { - - var rows = getRows(this.$el.children); - - return { - rows: rows, - columns: getColumns(rows) - }; - }, - - write: function(ref) { - var columns = ref.columns; - var rows = ref.rows; - - for (var i = 0; i < rows.length; i++) { - for (var j = 0; j < rows[i].length; j++) { - toggleClass(rows[i][j], this.margin, i !== 0); - toggleClass(rows[i][j], this.firstColumn, !!~columns[0].indexOf(rows[i][j])); - } - } - }, - - events: ['resize'] - - } - - }; - - function getRows(items) { - return sortBy(items, 'top', 'bottom'); - } - - function getColumns(rows) { - - var columns = []; - - for (var i = 0; i < rows.length; i++) { - var sorted = sortBy(rows[i], 'left', 'right'); - for (var j = 0; j < sorted.length; j++) { - columns[j] = !columns[j] ? sorted[j] : columns[j].concat(sorted[j]); - } - } - - return isRtl - ? columns.reverse() - : columns; - } - - function sortBy(items, startProp, endProp) { - - var sorted = [[]]; - - for (var i = 0; i < items.length; i++) { - - var el = items[i]; - - if (!isVisible(el)) { - continue; - } - - var dim = getOffset(el); - - for (var j = sorted.length - 1; j >= 0; j--) { - - var current = sorted[j]; - - if (!current[0]) { - current.push(el); - break; - } - - var startDim = (void 0); - if (current[0].offsetParent === el.offsetParent) { - startDim = getOffset(current[0]); - } else { - dim = getOffset(el, true); - startDim = getOffset(current[0], true); - } - - if (dim[startProp] >= startDim[endProp] - 1 && dim[startProp] !== startDim[startProp]) { - sorted.push([el]); - break; - } - - if (dim[endProp] - 1 > startDim[startProp] || dim[startProp] === startDim[startProp]) { - current.push(el); - break; - } - - if (j === 0) { - sorted.unshift([el]); - break; - } - - } - - } - - return sorted; - } - - function getOffset(element, offset) { - var assign; - - if ( offset === void 0 ) offset = false; - - var offsetTop = element.offsetTop; - var offsetLeft = element.offsetLeft; - var offsetHeight = element.offsetHeight; - var offsetWidth = element.offsetWidth; - - if (offset) { - (assign = offsetPosition(element), offsetTop = assign[0], offsetLeft = assign[1]); - } - - return { - top: offsetTop, - left: offsetLeft, - bottom: offsetTop + offsetHeight, - right: offsetLeft + offsetWidth - }; - } - - var grid = { - - extends: Margin, - - mixins: [Class], - - name: 'grid', - - props: { - masonry: Boolean, - parallax: Number - }, - - data: { - margin: 'uk-grid-margin', - clsStack: 'uk-grid-stack', - masonry: false, - parallax: 0 - }, - - connected: function() { - this.masonry && addClass(this.$el, 'uk-flex-top uk-flex-wrap-top'); - }, - - update: [ - - { - - write: function(ref) { - var columns = ref.columns; - - toggleClass(this.$el, this.clsStack, columns.length < 2); - }, - - events: ['resize'] - - }, - - { - - read: function(data) { - - var columns = data.columns; - var rows = data.rows; - - // Filter component makes elements positioned absolute - if (!columns.length || !this.masonry && !this.parallax || positionedAbsolute(this.$el)) { - data.translates = false; - return false; - } - - var translates = false; - - var nodes = children(this.$el); - var columnHeights = getColumnHeights(columns); - var margin = getMarginTop(nodes, this.margin) * (rows.length - 1); - var elHeight = Math.max.apply(Math, columnHeights) + margin; - - if (this.masonry) { - columns = columns.map(function (column) { return sortBy$1(column, 'offsetTop'); }); - translates = getTranslates(rows, columns); - } - - var padding = Math.abs(this.parallax); - if (padding) { - padding = columnHeights.reduce(function (newPadding, hgt, i) { return Math.max(newPadding, hgt + margin + (i % 2 ? padding : padding / 8) - elHeight); } - , 0); - } - - return {padding: padding, columns: columns, translates: translates, height: translates ? elHeight : ''}; - - }, - - write: function(ref) { - var height = ref.height; - var padding = ref.padding; - - - css(this.$el, 'paddingBottom', padding || ''); - height !== false && css(this.$el, 'height', height); - - }, - - events: ['resize'] - - }, - - { - - read: function(ref) { - var height$1 = ref.height; - - - if (positionedAbsolute(this.$el)) { - return false; - } - - return { - scrolled: this.parallax - ? scrolledOver(this.$el, height$1 ? height$1 - height(this.$el) : 0) * Math.abs(this.parallax) - : false - }; - }, - - write: function(ref) { - var columns = ref.columns; - var scrolled = ref.scrolled; - var translates = ref.translates; - - - if (scrolled === false && !translates) { - return; - } - - columns.forEach(function (column, i) { return column.forEach(function (el, j) { return css(el, 'transform', !scrolled && !translates ? '' : ("translateY(" + ((translates && -translates[i][j]) + (scrolled ? i % 2 ? scrolled : scrolled / 8 : 0)) + "px)")); } - ); } - ); - - }, - - events: ['scroll', 'resize'] - - } - - ] - - }; - - function positionedAbsolute(el) { - return children(el).some(function (el) { return css(el, 'position') === 'absolute'; }); - } - - function getTranslates(rows, columns) { - - var rowHeights = rows.map(function (row) { return Math.max.apply(Math, row.map(function (el) { return el.offsetHeight; })); } - ); - - return columns.map(function (elements) { - var prev = 0; - return elements.map(function (element, row) { return prev += row - ? rowHeights[row - 1] - elements[row - 1].offsetHeight - : 0; } - ); - }); - } - - function getMarginTop(nodes, cls) { - - var ref = nodes.filter(function (el) { return hasClass(el, cls); }); - var node = ref[0]; - - return toFloat(node - ? css(node, 'marginTop') - : css(nodes[0], 'paddingLeft')); - } - - function getColumnHeights(columns) { - return columns.map(function (column) { return column.reduce(function (sum, el) { return sum + el.offsetHeight; }, 0); } - ); - } - - // IE 11 fix (min-height on a flex container won't apply to its flex items) - var FlexBug = isIE ? { - - props: { - selMinHeight: String - }, - - data: { - selMinHeight: false, - forceHeight: false - }, - - computed: { - - elements: function(ref, $el) { - var selMinHeight = ref.selMinHeight; - - return selMinHeight ? $$(selMinHeight, $el) : [$el]; - } - - }, - - update: [ - - { - - read: function() { - css(this.elements, 'height', ''); - }, - - order: -5, - - events: ['resize'] - - }, - - { - - write: function() { - var this$1 = this; - - this.elements.forEach(function (el) { - var height = toFloat(css(el, 'minHeight')); - if (height && (this$1.forceHeight || Math.round(height + boxModelAdjust(el, 'height', 'content-box')) >= el.offsetHeight)) { - css(el, 'height', height); - } - }); - }, - - order: 5, - - events: ['resize'] - - } - - ] - - } : {}; - - var heightMatch = { - - mixins: [FlexBug], - - args: 'target', - - props: { - target: String, - row: Boolean - }, - - data: { - target: '> *', - row: true, - forceHeight: true - }, - - computed: { - - elements: function(ref, $el) { - var target = ref.target; - - return $$(target, $el); - } - - }, - - update: { - - read: function() { - return { - rows: (this.row ? getRows(this.elements) : [this.elements]).map(match$1) - }; - }, - - write: function(ref) { - var rows = ref.rows; - - rows.forEach(function (ref) { - var heights = ref.heights; - var elements = ref.elements; - - return elements.forEach(function (el, i) { return css(el, 'minHeight', heights[i]); } - ); - } - ); - }, - - events: ['resize'] - - } - - }; - - function match$1(elements) { - - if (elements.length < 2) { - return {heights: [''], elements: elements}; - } - - var heights = elements.map(getHeight); - var max = Math.max.apply(Math, heights); - var hasMinHeight = elements.some(function (el) { return el.style.minHeight; }); - var hasShrunk = elements.some(function (el, i) { return !el.style.minHeight && heights[i] < max; }); - - if (hasMinHeight && hasShrunk) { - css(elements, 'minHeight', ''); - heights = elements.map(getHeight); - max = Math.max.apply(Math, heights); - } - - heights = elements.map(function (el, i) { return heights[i] === max && toFloat(el.style.minHeight).toFixed(2) !== max.toFixed(2) ? '' : max; } - ); - - return {heights: heights, elements: elements}; - } - - function getHeight(element) { - - var style = false; - if (!isVisible(element)) { - style = element.style.display; - css(element, 'display', 'block', 'important'); - } - - var height = dimensions(element).height - boxModelAdjust(element, 'height', 'content-box'); - - if (style !== false) { - css(element, 'display', style); - } - - return height; - } - - var heightViewport = { - - mixins: [FlexBug], - - props: { - expand: Boolean, - offsetTop: Boolean, - offsetBottom: Boolean, - minHeight: Number - }, - - data: { - expand: false, - offsetTop: false, - offsetBottom: false, - minHeight: 0 - }, - - update: { - - read: function(ref) { - var prev = ref.minHeight; - - - if (!isVisible(this.$el)) { - return false; - } - - var minHeight = ''; - var box = boxModelAdjust(this.$el, 'height', 'content-box'); - - if (this.expand) { - - minHeight = height(window) - (dimensions(document.documentElement).height - dimensions(this.$el).height) - box || ''; - - } else { - - // on mobile devices (iOS and Android) window.innerHeight !== 100vh - minHeight = 'calc(100vh'; - - if (this.offsetTop) { - - var ref$1 = offset(this.$el); - var top = ref$1.top; - minHeight += top > 0 && top < height(window) / 2 ? (" - " + top + "px") : ''; - - } - - if (this.offsetBottom === true) { - - minHeight += " - " + (dimensions(this.$el.nextElementSibling).height) + "px"; - - } else if (isNumeric(this.offsetBottom)) { - - minHeight += " - " + (this.offsetBottom) + "vh"; - - } else if (this.offsetBottom && endsWith(this.offsetBottom, 'px')) { - - minHeight += " - " + (toFloat(this.offsetBottom)) + "px"; - - } else if (isString(this.offsetBottom)) { - - minHeight += " - " + (dimensions(query(this.offsetBottom, this.$el)).height) + "px"; - - } - - minHeight += (box ? (" - " + box + "px") : '') + ")"; - - } - - return {minHeight: minHeight, prev: prev}; - }, - - write: function(ref) { - var minHeight = ref.minHeight; - var prev = ref.prev; - - - css(this.$el, {minHeight: minHeight}); - - if (minHeight !== prev) { - this.$update(this.$el, 'resize'); - } - - if (this.minHeight && toFloat(css(this.$el, 'minHeight')) < this.minHeight) { - css(this.$el, 'minHeight', this.minHeight); - } - - }, - - events: ['resize'] - - } - - }; - - var SVG = { - - args: 'src', - - props: { - id: Boolean, - icon: String, - src: String, - style: String, - width: Number, - height: Number, - ratio: Number, - class: String, - strokeAnimation: Boolean, - focusable: Boolean, // IE 11 - attributes: 'list' - }, - - data: { - ratio: 1, - include: ['style', 'class', 'focusable'], - class: '', - strokeAnimation: false - }, - - beforeConnect: function() { - this.class += ' uk-svg'; - }, - - connected: function() { - var this$1 = this; - var assign; - - - if (!this.icon && includes(this.src, '#')) { - (assign = this.src.split('#'), this.src = assign[0], this.icon = assign[1]); - } - - this.svg = this.getSvg().then(function (el) { - - if (this$1._connected) { - - var svg = insertSVG(el, this$1.$el); - - if (this$1.svgEl && svg !== this$1.svgEl) { - remove$1(this$1.svgEl); - } - - this$1.applyAttributes(svg, el); - this$1.$emit(); - return this$1.svgEl = svg; - } - - }, noop); - - }, - - disconnected: function() { - var this$1 = this; - - - this.svg.then(function (svg) { - if (!this$1._connected) { - - if (isVoidElement(this$1.$el)) { - this$1.$el.hidden = false; - } - - remove$1(svg); - this$1.svgEl = null; - } - }); - - this.svg = null; - - }, - - update: { - - read: function() { - return !!(this.strokeAnimation && this.svgEl && isVisible(this.svgEl)); - }, - - write: function() { - applyAnimation(this.svgEl); - }, - - type: ['resize'] - - }, - - methods: { - - getSvg: function() { - var this$1 = this; - - return loadSVG(this.src).then(function (svg) { return parseSVG(svg, this$1.icon) || Promise$1.reject('SVG not found.'); } - ); - }, - - applyAttributes: function(el, ref) { - var this$1 = this; - - - for (var prop in this.$options.props) { - if (includes(this.include, prop) && (prop in this)) { - attr(el, prop, this[prop]); - } - } - - for (var attribute in this.attributes) { - var ref$1 = this.attributes[attribute].split(':', 2); - var prop$1 = ref$1[0]; - var value = ref$1[1]; - attr(el, prop$1, value); - } - - if (!this.id) { - removeAttr(el, 'id'); - } - - var props = ['width', 'height']; - var dimensions = props.map(function (prop) { return this$1[prop]; }); - - if (!dimensions.some(function (val) { return val; })) { - dimensions = props.map(function (prop) { return attr(ref, prop); }); - } - - var viewBox = attr(ref, 'viewBox'); - if (viewBox && !dimensions.some(function (val) { return val; })) { - dimensions = viewBox.split(' ').slice(2); - } - - dimensions.forEach(function (val, i) { return attr(el, props[i], toFloat(val) * this$1.ratio || null); } - ); - - } - - } - - }; - - var loadSVG = memoize(function (src) { return new Promise$1(function (resolve, reject) { - - if (!src) { - reject(); - return; - } - - if (startsWith(src, 'data:')) { - resolve(decodeURIComponent(src.split(',')[1])); - } else { - - ajax(src).then( - function (xhr) { return resolve(xhr.response); }, - function () { return reject('SVG not found.'); } - ); - - } - }); } - ); - - function parseSVG(svg, icon) { - - if (icon && includes(svg, '/g; - var symbols = {}; - - function parseSymbols(svg, icon) { - - if (!symbols[svg]) { - - symbols[svg] = {}; - - symbolRe.lastIndex = 0; - - var match; - while ((match = symbolRe.exec(svg))) { - symbols[svg][match[3]] = ""; - } - - } - - return symbols[svg][icon]; - } - - function applyAnimation(el) { - - var length = getMaxPathLength(el); - - if (length) { - el.style.setProperty('--uk-animation-stroke', length); - } - - } - - function getMaxPathLength(el) { - return Math.ceil(Math.max.apply(Math, [ 0 ].concat( $$('[stroke]', el).map(function (stroke) { - try { - return stroke.getTotalLength(); - } catch (e) { - return 0; - } - }) ))); - } - - function insertSVG(el, root) { - - if (isVoidElement(root) || root.tagName === 'CANVAS') { - - root.hidden = true; - - var next = root.nextElementSibling; - return equals(el, next) - ? next - : after(root, el); - - } - - var last = root.lastElementChild; - return equals(el, last) - ? last - : append(root, el); - } - - function equals(el, other) { - return isSVG(el) && isSVG(other) && innerHTML(el) === innerHTML(other); - } - - function isSVG(el) { - return el && el.tagName === 'svg'; - } - - function innerHTML(el) { - return (el.innerHTML || (new XMLSerializer()).serializeToString(el).replace(/(.*?)<\/svg>/g, '$1')).replace(/\s/g, ''); - } - - var closeIcon = ""; - - var closeLarge = ""; - - var marker = ""; - - var navbarToggleIcon = ""; - - var overlayIcon = ""; - - var paginationNext = ""; - - var paginationPrevious = ""; - - var searchIcon = ""; - - var searchLarge = ""; - - var searchNavbar = ""; - - var slidenavNext = ""; - - var slidenavNextLarge = ""; - - var slidenavPrevious = ""; - - var slidenavPreviousLarge = ""; - - var spinner = ""; - - var totop = ""; - - var icons = { - spinner: spinner, - totop: totop, - marker: marker, - 'close-icon': closeIcon, - 'close-large': closeLarge, - 'navbar-toggle-icon': navbarToggleIcon, - 'overlay-icon': overlayIcon, - 'pagination-next': paginationNext, - 'pagination-previous': paginationPrevious, - 'search-icon': searchIcon, - 'search-large': searchLarge, - 'search-navbar': searchNavbar, - 'slidenav-next': slidenavNext, - 'slidenav-next-large': slidenavNextLarge, - 'slidenav-previous': slidenavPrevious, - 'slidenav-previous-large': slidenavPreviousLarge - }; - - var Icon = { - - install: install$3, - - extends: SVG, - - args: 'icon', - - props: ['icon'], - - data: { - include: ['focusable'] - }, - - isIcon: true, - - beforeConnect: function() { - addClass(this.$el, 'uk-icon'); - }, - - methods: { - - getSvg: function() { - - var icon = getIcon(this.icon); - - if (!icon) { - return Promise$1.reject('Icon not found.'); - } - - return Promise$1.resolve(icon); - } - - } - - }; - - var IconComponent = { - - args: false, - - extends: Icon, - - data: function (vm) { return ({ - icon: hyphenate(vm.constructor.options.name) - }); }, - - beforeConnect: function() { - addClass(this.$el, this.$name); - } - - }; - - var Slidenav = { - - extends: IconComponent, - - beforeConnect: function() { - addClass(this.$el, 'uk-slidenav'); - }, - - computed: { - - icon: function(ref, $el) { - var icon = ref.icon; - - return hasClass($el, 'uk-slidenav-large') - ? (icon + "-large") - : icon; - } - - } - - }; - - var Search = { - - extends: IconComponent, - - computed: { - - icon: function(ref, $el) { - var icon = ref.icon; - - return hasClass($el, 'uk-search-icon') && parents($el, '.uk-search-large').length - ? 'search-large' - : parents($el, '.uk-search-navbar').length - ? 'search-navbar' - : icon; - } - - } - - }; - - var Close = { - - extends: IconComponent, - - computed: { - - icon: function() { - return ("close-" + (hasClass(this.$el, 'uk-close-large') ? 'large' : 'icon')); - } - - } - - }; - - var Spinner = { - - extends: IconComponent, - - connected: function() { - var this$1 = this; - - this.svg.then(function (svg) { return svg && this$1.ratio !== 1 && css($('circle', svg), 'strokeWidth', 1 / this$1.ratio); }); - } - - }; - - var parsed = {}; - function install$3(UIkit) { - UIkit.icon.add = function (name, svg) { - var obj; - - - var added = isString(name) ? (( obj = {}, obj[name] = svg, obj )) : name; - each(added, function (svg, name) { - icons[name] = svg; - delete parsed[name]; - }); - - if (UIkit._initialized) { - apply$1(document.body, function (el) { return each(UIkit.getComponents(el), function (cmp) { - cmp.$options.isIcon && cmp.icon in added && cmp.$reset(); - }); } - ); - } - }; - } - - function getIcon(icon) { - - if (!icons[icon]) { - return null; - } - - if (!parsed[icon]) { - parsed[icon] = $((icons[applyRtl(icon)] || icons[icon]).trim()); - } - - return parsed[icon].cloneNode(true); - } - - function applyRtl(icon) { - return isRtl ? swap(swap(icon, 'left', 'right'), 'previous', 'next') : icon; - } - - var img = { - - args: 'dataSrc', - - props: { - dataSrc: String, - dataSrcset: Boolean, - sizes: String, - width: Number, - height: Number, - offsetTop: String, - offsetLeft: String, - target: String - }, - - data: { - dataSrc: '', - dataSrcset: false, - sizes: false, - width: false, - height: false, - offsetTop: '50vh', - offsetLeft: '50vw', - target: false - }, - - computed: { - - cacheKey: function(ref) { - var dataSrc = ref.dataSrc; - - return ((this.$name) + "." + dataSrc); - }, - - width: function(ref) { - var width = ref.width; - var dataWidth = ref.dataWidth; - - return width || dataWidth; - }, - - height: function(ref) { - var height = ref.height; - var dataHeight = ref.dataHeight; - - return height || dataHeight; - }, - - sizes: function(ref) { - var sizes = ref.sizes; - var dataSizes = ref.dataSizes; - - return sizes || dataSizes; - }, - - isImg: function(_, $el) { - return isImg($el); - }, - - target: { - - get: function(ref) { - var target = ref.target; - - return [this.$el ].concat( queryAll(target, this.$el)); - }, - - watch: function() { - this.observe(); - } - - }, - - offsetTop: function(ref) { - var offsetTop = ref.offsetTop; - - return toPx(offsetTop, 'height'); - }, - - offsetLeft: function(ref) { - var offsetLeft = ref.offsetLeft; - - return toPx(offsetLeft, 'width'); - } - - }, - - connected: function() { - - if (!window.IntersectionObserver) { - setSrcAttrs(this.$el, this.dataSrc, this.dataSrcset, this.sizes); - return; - } - - if (storage[this.cacheKey]) { - setSrcAttrs(this.$el, storage[this.cacheKey], this.dataSrcset, this.sizes); - } else if (this.isImg && this.width && this.height) { - setSrcAttrs(this.$el, getPlaceholderImage(this.width, this.height, this.sizes)); - } - - this.observer = new IntersectionObserver(this.load, { - rootMargin: ((this.offsetTop) + "px " + (this.offsetLeft) + "px") - }); - - requestAnimationFrame(this.observe); - - }, - - disconnected: function() { - this.observer && this.observer.disconnect(); - }, - - update: { - - read: function(ref) { - var this$1 = this; - var image = ref.image; - - - if (!this.observer) { - return false; - } - - if (!image && document.readyState === 'complete') { - this.load(this.observer.takeRecords()); - } - - if (this.isImg) { - return false; - } - - image && image.then(function (img) { return img && img.currentSrc !== '' && setSrcAttrs(this$1.$el, currentSrc(img)); }); - - }, - - write: function(data) { - - if (this.dataSrcset && window.devicePixelRatio !== 1) { - - var bgSize = css(this.$el, 'backgroundSize'); - if (bgSize.match(/^(auto\s?)+$/) || toFloat(bgSize) === data.bgSize) { - data.bgSize = getSourceSize(this.dataSrcset, this.sizes); - css(this.$el, 'backgroundSize', ((data.bgSize) + "px")); - } - - } - - }, - - events: ['resize'] - - }, - - methods: { - - load: function(entries) { - var this$1 = this; - - - // Old chromium based browsers (UC Browser) did not implement `isIntersecting` - if (!entries.some(function (entry) { return isUndefined(entry.isIntersecting) || entry.isIntersecting; })) { - return; - } - - this._data.image = getImage(this.dataSrc, this.dataSrcset, this.sizes).then(function (img) { - - setSrcAttrs(this$1.$el, currentSrc(img), img.srcset, img.sizes); - storage[this$1.cacheKey] = currentSrc(img); - return img; - - }, function (e) { return trigger(this$1.$el, new e.constructor(e.type, e)); }); - - this.observer.disconnect(); - }, - - observe: function() { - var this$1 = this; - - if (this._connected && !this._data.image) { - this.target.forEach(function (el) { return this$1.observer.observe(el); }); - } - } - - } - - }; - - function setSrcAttrs(el, src, srcset, sizes) { - - if (isImg(el)) { - sizes && (el.sizes = sizes); - srcset && (el.srcset = srcset); - src && (el.src = src); - } else if (src) { - - var change = !includes(el.style.backgroundImage, src); - if (change) { - css(el, 'backgroundImage', ("url("https://github.com/+%20(escape(src)) + ")")); - trigger(el, createEvent('load', false)); - } - - } - - } - - function getPlaceholderImage(width, height, sizes) { - var assign; - - - if (sizes) { - ((assign = Dimensions.ratio({width: width, height: height}, 'width', toPx(sizesToPixel(sizes))), width = assign.width, height = assign.height)); - } - - return ("data:image/svg+xml;utf8,"); - } - - var sizesRe = /\s*(.*?)\s*(\w+|calc\(.*?\))\s*(?:,|$)/g; - function sizesToPixel(sizes) { - var matches; - - sizesRe.lastIndex = 0; - - while ((matches = sizesRe.exec(sizes))) { - if (!matches[1] || window.matchMedia(matches[1]).matches) { - matches = evaluateSize(matches[2]); - break; - } - } - - return matches || '100vw'; - } - - var sizeRe = /\d+(?:\w+|%)/g; - var additionRe = /[+-]?(\d+)/g; - function evaluateSize(size) { - return startsWith(size, 'calc') - ? size - .slice(5, -1) - .replace(sizeRe, function (size) { return toPx(size); }) - .replace(/ /g, '') - .match(additionRe) - .reduce(function (a, b) { return a + +b; }, 0) - : size; - } - - var srcSetRe = /\s+\d+w\s*(?:,|$)/g; - function getSourceSize(srcset, sizes) { - var srcSize = toPx(sizesToPixel(sizes)); - var descriptors = (srcset.match(srcSetRe) || []).map(toFloat).sort(function (a, b) { return a - b; }); - - return descriptors.filter(function (size) { return size >= srcSize; })[0] || descriptors.pop() || ''; - } - - function isImg(el) { - return el.tagName === 'IMG'; - } - - function currentSrc(el) { - return el.currentSrc || el.src; - } - - var key = '__test__'; - var storage; - - // workaround for Safari's private browsing mode and accessing sessionStorage in Blink - try { - storage = window.sessionStorage || {}; - storage[key] = 1; - delete storage[key]; - } catch (e) { - storage = {}; - } - - var Media = { - - props: { - media: Boolean - }, - - data: { - media: false - }, - - computed: { - - matchMedia: function() { - var media = toMedia(this.media); - return !media || window.matchMedia(media).matches; - } - - } - - }; - - function toMedia(value) { - - if (isString(value)) { - if (value[0] === '@') { - var name = "breakpoint-" + (value.substr(1)); - value = toFloat(getCssVar(name)); - } else if (isNaN(value)) { - return value; - } - } - - return value && !isNaN(value) ? ("(min-width: " + value + "px)") : false; - } - - var leader = { - - mixins: [Class, Media], - - props: { - fill: String - }, - - data: { - fill: '', - clsWrapper: 'uk-leader-fill', - clsHide: 'uk-leader-hide', - attrFill: 'data-fill' - }, - - computed: { - - fill: function(ref) { - var fill = ref.fill; - - return fill || getCssVar('leader-fill-content'); - } - - }, - - connected: function() { - var assign; - - (assign = wrapInner(this.$el, ("")), this.wrapper = assign[0]); - }, - - disconnected: function() { - unwrap(this.wrapper.childNodes); - }, - - update: { - - read: function(ref) { - var changed = ref.changed; - var width = ref.width; - - - var prev = width; - - width = Math.floor(this.$el.offsetWidth / 2); - - return { - width: width, - fill: this.fill, - changed: changed || prev !== width, - hide: !this.matchMedia - }; - }, - - write: function(data) { - - toggleClass(this.wrapper, this.clsHide, data.hide); - - if (data.changed) { - data.changed = false; - attr(this.wrapper, this.attrFill, new Array(data.width).join(data.fill)); - } - - }, - - events: ['resize'] - - } - - }; - - var active = []; - - var Modal = { - - mixins: [Class, Container, Togglable], - - props: { - selPanel: String, - selClose: String, - escClose: Boolean, - bgClose: Boolean, - stack: Boolean - }, - - data: { - cls: 'uk-open', - escClose: true, - bgClose: true, - overlay: true, - stack: false - }, - - computed: { - - panel: function(ref, $el) { - var selPanel = ref.selPanel; - - return $(selPanel, $el); - }, - - transitionElement: function() { - return this.panel; - }, - - bgClose: function(ref) { - var bgClose = ref.bgClose; - - return bgClose && this.panel; - } - - }, - - beforeDisconnect: function() { - if (this.isToggled()) { - this.toggleElement(this.$el, false, false); - } - }, - - events: [ - - { - - name: 'click', - - delegate: function() { - return this.selClose; - }, - - handler: function(e) { - e.preventDefault(); - this.hide(); - } - - }, - - { - - name: 'toggle', - - self: true, - - handler: function(e) { - - if (e.defaultPrevented) { - return; - } - - e.preventDefault(); - - if (this.isToggled() === includes(active, this)) { - this.toggle(); - } - } - - }, - - { - name: 'beforeshow', - - self: true, - - handler: function(e) { - - if (includes(active, this)) { - return false; - } - - if (!this.stack && active.length) { - Promise$1.all(active.map(function (modal) { return modal.hide(); })).then(this.show); - e.preventDefault(); - } else { - active.push(this); - } - } - - }, - - { - - name: 'show', - - self: true, - - handler: function() { - var this$1 = this; - - - var docEl = document.documentElement; - - if (width(window) > docEl.clientWidth && this.overlay) { - css(document.body, 'overflowY', 'scroll'); - } - - if (this.stack) { - css(this.$el, 'zIndex', toFloat(css(this.$el, 'zIndex')) + active.length); - } - - addClass(docEl, this.clsPage); - - if (this.bgClose) { - once(this.$el, 'hide', on(document, pointerDown, function (ref) { - var target = ref.target; - - - if (last(active) !== this$1 || this$1.overlay && !within(target, this$1.$el) || within(target, this$1.panel)) { - return; - } - - once(document, (pointerUp + " " + pointerCancel + " scroll"), function (ref) { - var defaultPrevented = ref.defaultPrevented; - var type = ref.type; - var newTarget = ref.target; - - if (!defaultPrevented && type === pointerUp && target === newTarget) { - this$1.hide(); - } - }, true); - - }), {self: true}); - } - - if (this.escClose) { - once(this.$el, 'hide', on(document, 'keydown', function (e) { - if (e.keyCode === 27 && last(active) === this$1) { - this$1.hide(); - } - }), {self: true}); - } - } - - }, - - { - - name: 'hidden', - - self: true, - - handler: function() { - var this$1 = this; - - - if (includes(active, this)) { - active.splice(active.indexOf(this), 1); - } - - if (!active.length) { - css(document.body, 'overflowY', ''); - } - - css(this.$el, 'zIndex', ''); - - if (!active.some(function (modal) { return modal.clsPage === this$1.clsPage; })) { - removeClass(document.documentElement, this.clsPage); - } - - } - - } - - ], - - methods: { - - toggle: function() { - return this.isToggled() ? this.hide() : this.show(); - }, - - show: function() { - var this$1 = this; - - if (this.container && parent(this.$el) !== this.container) { - append(this.container, this.$el); - return new Promise$1(function (resolve) { return requestAnimationFrame(function () { return this$1.show().then(resolve); } - ); } - ); - } - - return this.toggleElement(this.$el, true, animate(this)); - }, - - hide: function() { - return this.toggleElement(this.$el, false, animate(this)); - } - - } - - }; - - function animate(ref) { - var transitionElement = ref.transitionElement; - var _toggle = ref._toggle; - - return function (el, show) { return new Promise$1(function (resolve, reject) { return once(el, 'show hide', function () { - el._reject && el._reject(); - el._reject = reject; - - _toggle(el, show); - - var off = once(transitionElement, 'transitionstart', function () { - once(transitionElement, 'transitionend transitioncancel', resolve, {self: true}); - clearTimeout(timer); - }, {self: true}); - - var timer = setTimeout(function () { - off(); - resolve(); - }, toMs(css(transitionElement, 'transitionDuration'))); - - }); } - ).then(function () { return delete el._reject; }); }; - } - - var modal = { - - install: install$2, - - mixins: [Modal], - - data: { - clsPage: 'uk-modal-page', - selPanel: '.uk-modal-dialog', - selClose: '.uk-modal-close, .uk-modal-close-default, .uk-modal-close-outside, .uk-modal-close-full' - }, - - events: [ - - { - name: 'show', - - self: true, - - handler: function() { - - if (hasClass(this.panel, 'uk-margin-auto-vertical')) { - addClass(this.$el, 'uk-flex'); - } else { - css(this.$el, 'display', 'block'); - } - - height(this.$el); // force reflow - } - }, - - { - name: 'hidden', - - self: true, - - handler: function() { - - css(this.$el, 'display', ''); - removeClass(this.$el, 'uk-flex'); - - } - } - - ] - - }; - - function install$2(ref) { - var modal = ref.modal; - - - modal.dialog = function (content, options) { - - var dialog = modal( - ("
" + content + "
"), - options - ); - - dialog.show(); - - on(dialog.$el, 'hidden', function () { return Promise$1.resolve().then(function () { return dialog.$destroy(true); } - ); }, {self: true} - ); - - return dialog; - }; - - modal.alert = function (message, options) { - return openDialog( - function (ref) { - var labels = ref.labels; - - return ("
" + (isString(message) ? message : html(message)) + "
"); - }, - options, - function (deferred) { return deferred.resolve(); } - ); - }; - - modal.confirm = function (message, options) { - return openDialog( - function (ref) { - var labels = ref.labels; - - return ("
" + (isString(message) ? message : html(message)) + "
"); - }, - options, - function (deferred) { return deferred.reject(); } - ); - }; - - modal.prompt = function (message, value, options) { - return openDialog( - function (ref) { - var labels = ref.labels; - - return ("
"); - }, - options, - function (deferred) { return deferred.resolve(null); }, - function (dialog) { return $('input', dialog.$el).value; } - ); - }; - - modal.labels = { - ok: 'Ok', - cancel: 'Cancel' - }; - - function openDialog(tmpl, options, hideFn, submitFn) { - - options = assign({bgClose: false, escClose: true, labels: modal.labels}, options); - - var dialog = modal.dialog(tmpl(options), options); - var deferred = new Deferred(); - - var resolved = false; - - on(dialog.$el, 'submit', 'form', function (e) { - e.preventDefault(); - deferred.resolve(submitFn && submitFn(dialog)); - resolved = true; - dialog.hide(); - }); - - on(dialog.$el, 'hide', function () { return !resolved && hideFn(deferred); }); - - deferred.promise.dialog = dialog; - - return deferred.promise; - } - - } - - var nav = { - - extends: Accordion, - - data: { - targets: '> .uk-parent', - toggle: '> a', - content: '> ul' - } - - }; - - var navItem = '.uk-navbar-nav > li > a, .uk-navbar-item, .uk-navbar-toggle'; - - var navbar = { - - mixins: [Class, Container, FlexBug], - - props: { - dropdown: String, - mode: 'list', - align: String, - offset: Number, - boundary: Boolean, - boundaryAlign: Boolean, - clsDrop: String, - delayShow: Number, - delayHide: Number, - dropbar: Boolean, - dropbarMode: String, - dropbarAnchor: Boolean, - duration: Number - }, - - data: { - dropdown: navItem, - align: !isRtl ? 'left' : 'right', - clsDrop: 'uk-navbar-dropdown', - mode: undefined, - offset: undefined, - delayShow: undefined, - delayHide: undefined, - boundaryAlign: undefined, - flip: 'x', - boundary: true, - dropbar: false, - dropbarMode: 'slide', - dropbarAnchor: false, - duration: 200, - forceHeight: true, - selMinHeight: navItem, - container: false - }, - - computed: { - - boundary: function(ref, $el) { - var boundary = ref.boundary; - var boundaryAlign = ref.boundaryAlign; - - return (boundary === true || boundaryAlign) ? $el : boundary; - }, - - dropbarAnchor: function(ref, $el) { - var dropbarAnchor = ref.dropbarAnchor; - - return query(dropbarAnchor, $el); - }, - - pos: function(ref) { - var align = ref.align; - - return ("bottom-" + align); - }, - - dropbar: { - - get: function(ref) { - var dropbar = ref.dropbar; - - - if (!dropbar) { - return null; - } - - dropbar = this._dropbar || query(dropbar, this.$el) || $('+ .uk-navbar-dropbar', this.$el); - - return dropbar ? dropbar : (this._dropbar = $('
')); - - }, - - watch: function(dropbar) { - addClass(dropbar, 'uk-navbar-dropbar'); - }, - - immediate: true - - }, - - dropContainer: function(_, $el) { - return this.container || $el; - }, - - dropdowns: { - - get: function(ref, $el) { - var clsDrop = ref.clsDrop; - - var dropdowns = $$(("." + clsDrop), $el); - - if (this.container !== $el) { - $$(("." + clsDrop), this.container).forEach(function (el) { return !includes(dropdowns, el) && dropdowns.push(el); }); - } - - return dropdowns; - }, - - watch: function(dropdowns) { - var this$1 = this; - - this.$create( - 'drop', - dropdowns.filter(function (el) { return !this$1.getDropdown(el); }), - assign({}, this.$props, {boundary: this.boundary, pos: this.pos, offset: this.dropbar || this.offset}) - ); - }, - - immediate: true - - } - - }, - - disconnected: function() { - this.dropbar && remove$1(this.dropbar); - delete this._dropbar; - }, - - events: [ - - { - name: 'mouseover', - - delegate: function() { - return this.dropdown; - }, - - handler: function(ref) { - var current = ref.current; - - var active = this.getActive(); - if (active && active.target && !within(active.target, current) && !active.tracker.movesTo(active.$el)) { - active.hide(false); - } - } - - }, - - { - name: 'mouseleave', - - el: function() { - return this.dropbar; - }, - - handler: function() { - var active = this.getActive(); - - if (active && !this.dropdowns.some(function (el) { return matches(el, ':hover'); })) { - active.hide(); - } - } - }, - - { - name: 'beforeshow', - - el: function() { - return this.dropContainer; - }, - - filter: function() { - return this.dropbar; - }, - - handler: function() { - - if (!parent(this.dropbar)) { - after(this.dropbarAnchor || this.$el, this.dropbar); - } - - } - }, - - { - name: 'show', - - el: function() { - return this.dropContainer; - }, - - filter: function() { - return this.dropbar; - }, - - handler: function(_, ref) { - var $el = ref.$el; - var dir = ref.dir; - - if (!hasClass($el, this.clsDrop)) { - return; - } - - if (this.dropbarMode === 'slide') { - addClass(this.dropbar, 'uk-navbar-dropbar-slide'); - } - - this.clsDrop && addClass($el, ((this.clsDrop) + "-dropbar")); - - if (dir === 'bottom') { - this.transitionTo($el.offsetHeight + toFloat(css($el, 'marginTop')) + toFloat(css($el, 'marginBottom')), $el); - } - } - }, - - { - name: 'beforehide', - - el: function() { - return this.dropContainer; - }, - - filter: function() { - return this.dropbar; - }, - - handler: function(e, ref) { - var $el = ref.$el; - - - var active = this.getActive(); - - if (matches(this.dropbar, ':hover') && active && active.$el === $el) { - e.preventDefault(); - } - } - }, - - { - name: 'hide', - - el: function() { - return this.dropContainer; - }, - - filter: function() { - return this.dropbar; - }, - - handler: function(_, ref) { - var $el = ref.$el; - - if (!hasClass($el, this.clsDrop)) { - return; - } - - var active = this.getActive(); - - if (!active || active && active.$el === $el) { - this.transitionTo(0); - } - } - } - - ], - - methods: { - - getActive: function() { - return active$1 && includes(active$1.mode, 'hover') && within(active$1.target, this.$el) && active$1; - }, - - transitionTo: function(newHeight, el) { - var this$1 = this; - - - var ref = this; - var dropbar = ref.dropbar; - var oldHeight = isVisible(dropbar) ? height(dropbar) : 0; - - el = oldHeight < newHeight && el; - - css(el, 'clip', ("rect(0," + (el.offsetWidth) + "px," + oldHeight + "px,0)")); - - height(dropbar, oldHeight); - - Transition.cancel([el, dropbar]); - return Promise$1.all([ - Transition.start(dropbar, {height: newHeight}, this.duration), - Transition.start(el, {clip: ("rect(0," + (el.offsetWidth) + "px," + newHeight + "px,0)")}, this.duration) - ]) - .catch(noop) - .then(function () { - css(el, {clip: ''}); - this$1.$update(dropbar); - }); - }, - - getDropdown: function(el) { - return this.$getComponent(el, 'drop') || this.$getComponent(el, 'dropdown'); - } - - } - - }; - - var offcanvas = { - - mixins: [Modal], - - args: 'mode', - - props: { - mode: String, - flip: Boolean, - overlay: Boolean - }, - - data: { - mode: 'slide', - flip: false, - overlay: false, - clsPage: 'uk-offcanvas-page', - clsContainer: 'uk-offcanvas-container', - selPanel: '.uk-offcanvas-bar', - clsFlip: 'uk-offcanvas-flip', - clsContainerAnimation: 'uk-offcanvas-container-animation', - clsSidebarAnimation: 'uk-offcanvas-bar-animation', - clsMode: 'uk-offcanvas', - clsOverlay: 'uk-offcanvas-overlay', - selClose: '.uk-offcanvas-close', - container: false - }, - - computed: { - - clsFlip: function(ref) { - var flip = ref.flip; - var clsFlip = ref.clsFlip; - - return flip ? clsFlip : ''; - }, - - clsOverlay: function(ref) { - var overlay = ref.overlay; - var clsOverlay = ref.clsOverlay; - - return overlay ? clsOverlay : ''; - }, - - clsMode: function(ref) { - var mode = ref.mode; - var clsMode = ref.clsMode; - - return (clsMode + "-" + mode); - }, - - clsSidebarAnimation: function(ref) { - var mode = ref.mode; - var clsSidebarAnimation = ref.clsSidebarAnimation; - - return mode === 'none' || mode === 'reveal' ? '' : clsSidebarAnimation; - }, - - clsContainerAnimation: function(ref) { - var mode = ref.mode; - var clsContainerAnimation = ref.clsContainerAnimation; - - return mode !== 'push' && mode !== 'reveal' ? '' : clsContainerAnimation; - }, - - transitionElement: function(ref) { - var mode = ref.mode; - - return mode === 'reveal' ? parent(this.panel) : this.panel; - } - - }, - - update: { - - read: function() { - if (this.isToggled() && !isVisible(this.$el)) { - this.hide(); - } - }, - - events: ['resize'] - - }, - - events: [ - - { - - name: 'click', - - delegate: function() { - return 'a[href^="#"]'; - }, - - handler: function(ref) { - var hash = ref.current.hash; - var defaultPrevented = ref.defaultPrevented; - - if (!defaultPrevented && hash && $(hash, document.body)) { - this.hide(); - } - } - - }, - - { - name: 'touchstart', - - passive: true, - - el: function() { - return this.panel; - }, - - handler: function(ref) { - var targetTouches = ref.targetTouches; - - - if (targetTouches.length === 1) { - this.clientY = targetTouches[0].clientY; - } - - } - - }, - - { - name: 'touchmove', - - self: true, - passive: false, - - filter: function() { - return this.overlay; - }, - - handler: function(e) { - e.cancelable && e.preventDefault(); - } - - }, - - { - name: 'touchmove', - - passive: false, - - el: function() { - return this.panel; - }, - - handler: function(e) { - - if (e.targetTouches.length !== 1) { - return; - } - - var clientY = e.targetTouches[0].clientY - this.clientY; - var ref = this.panel; - var scrollTop = ref.scrollTop; - var scrollHeight = ref.scrollHeight; - var clientHeight = ref.clientHeight; - - if (clientHeight >= scrollHeight - || scrollTop === 0 && clientY > 0 - || scrollHeight - scrollTop <= clientHeight && clientY < 0 - ) { - e.cancelable && e.preventDefault(); - } - - } - - }, - - { - name: 'show', - - self: true, - - handler: function() { - - if (this.mode === 'reveal' && !hasClass(parent(this.panel), this.clsMode)) { - wrapAll(this.panel, '
'); - addClass(parent(this.panel), this.clsMode); - } - - css(document.documentElement, 'overflowY', this.overlay ? 'hidden' : ''); - addClass(document.body, this.clsContainer, this.clsFlip); - css(document.body, 'touch-action', 'pan-y pinch-zoom'); - css(this.$el, 'display', 'block'); - addClass(this.$el, this.clsOverlay); - addClass(this.panel, this.clsSidebarAnimation, this.mode !== 'reveal' ? this.clsMode : ''); - - height(document.body); // force reflow - addClass(document.body, this.clsContainerAnimation); - - this.clsContainerAnimation && suppressUserScale(); - - - } - }, - - { - name: 'hide', - - self: true, - - handler: function() { - removeClass(document.body, this.clsContainerAnimation); - css(document.body, 'touch-action', ''); - } - }, - - { - name: 'hidden', - - self: true, - - handler: function() { - - this.clsContainerAnimation && resumeUserScale(); - - if (this.mode === 'reveal') { - unwrap(this.panel); - } - - removeClass(this.panel, this.clsSidebarAnimation, this.clsMode); - removeClass(this.$el, this.clsOverlay); - css(this.$el, 'display', ''); - removeClass(document.body, this.clsContainer, this.clsFlip); - - css(document.documentElement, 'overflowY', ''); - - } - }, - - { - name: 'swipeLeft swipeRight', - - handler: function(e) { - - if (this.isToggled() && endsWith(e.type, 'Left') ^ this.flip) { - this.hide(); - } - - } - } - - ] - - }; - - // Chrome in responsive mode zooms page upon opening offcanvas - function suppressUserScale() { - getViewport().content += ',user-scalable=0'; - } - - function resumeUserScale() { - var viewport = getViewport(); - viewport.content = viewport.content.replace(/,user-scalable=0$/, ''); - } - - function getViewport() { - return $('meta[name="viewport"]', document.head) || append(document.head, ''); - } - - var overflowAuto = { - - mixins: [Class], - - props: { - selContainer: String, - selContent: String - }, - - data: { - selContainer: '.uk-modal', - selContent: '.uk-modal-dialog' - }, - - computed: { - - container: function(ref, $el) { - var selContainer = ref.selContainer; - - return closest($el, selContainer); - }, - - content: function(ref, $el) { - var selContent = ref.selContent; - - return closest($el, selContent); - } - - }, - - connected: function() { - css(this.$el, 'minHeight', 150); - }, - - update: { - - read: function() { - - if (!this.content || !this.container || !isVisible(this.$el)) { - return false; - } - - return { - current: toFloat(css(this.$el, 'maxHeight')), - max: Math.max(150, height(this.container) - (dimensions(this.content).height - height(this.$el))) - }; - }, - - write: function(ref) { - var current = ref.current; - var max = ref.max; - - css(this.$el, 'maxHeight', max); - if (Math.round(current) !== Math.round(max)) { - trigger(this.$el, 'resize'); - } - }, - - events: ['resize'] - - } - - }; - - var responsive = { - - props: ['width', 'height'], - - connected: function() { - addClass(this.$el, 'uk-responsive-width'); - }, - - update: { - - read: function() { - return isVisible(this.$el) && this.width && this.height - ? {width: width(parent(this.$el)), height: this.height} - : false; - }, - - write: function(dim) { - height(this.$el, Dimensions.contain({ - height: this.height, - width: this.width - }, dim).height); - }, - - events: ['resize'] - - } - - }; - - var scroll = { - - props: { - offset: Number - }, - - data: { - offset: 0 - }, - - methods: { - - scrollTo: function(el) { - var this$1 = this; - - - el = el && $(el) || document.body; - - if (trigger(this.$el, 'beforescroll', [this, el])) { - scrollIntoView(el, {offset: this.offset}).then(function () { return trigger(this$1.$el, 'scrolled', [this$1, el]); } - ); - } - - } - - }, - - events: { - - click: function(e) { - - if (e.defaultPrevented) { - return; - } - - e.preventDefault(); - this.scrollTo(("#" + (escape(decodeURIComponent((this.$el.hash || '').substr(1)))))); - } - - } - - }; - - var stateKey = '_ukScrollspy'; - var scrollspy = { - - args: 'cls', - - props: { - cls: String, - target: String, - hidden: Boolean, - offsetTop: Number, - offsetLeft: Number, - repeat: Boolean, - delay: Number - }, - - data: function () { return ({ - cls: false, - target: false, - hidden: true, - offsetTop: 0, - offsetLeft: 0, - repeat: false, - delay: 0, - inViewClass: 'uk-scrollspy-inview' - }); }, - - computed: { - - elements: { - - get: function(ref, $el) { - var target = ref.target; - - return target ? $$(target, $el) : [$el]; - }, - - watch: function(elements) { - if (this.hidden) { - css(filter$1(elements, (":not(." + (this.inViewClass) + ")")), 'visibility', 'hidden'); - } - }, - - immediate: true - - } - - }, - - disconnected: function() { - var this$1 = this; - - this.elements.forEach(function (el) { - removeClass(el, this$1.inViewClass, el[stateKey] ? el[stateKey].cls : ''); - delete el[stateKey]; - }); - }, - - update: [ - - { - - read: function(data$1) { - var this$1 = this; - - - // Let child components be applied at least once first - if (!data$1.update) { - Promise$1.resolve().then(function () { - this$1.$emit(); - data$1.update = true; - }); - return false; - } - - this.elements.forEach(function (el) { - - if (!el[stateKey]) { - el[stateKey] = {cls: data(el, 'uk-scrollspy-class') || this$1.cls}; - } - - el[stateKey].show = isInView(el, this$1.offsetTop, this$1.offsetLeft); - - }); - - }, - - write: function(data) { - var this$1 = this; - - - this.elements.forEach(function (el) { - - var state = el[stateKey]; - - if (state.show && !state.inview && !state.queued) { - - state.queued = true; - - data.promise = (data.promise || Promise$1.resolve()).then(function () { return new Promise$1(function (resolve) { return setTimeout(resolve, this$1.delay); } - ); } - ).then(function () { - this$1.toggle(el, true); - setTimeout(function () { - state.queued = false; - this$1.$emit(); - }, 300); - }); - - } else if (!state.show && state.inview && !state.queued && this$1.repeat) { - - this$1.toggle(el, false); - - } - - }); - - }, - - events: ['scroll', 'resize'] - - } - - ], - - methods: { - - toggle: function(el, inview) { - - var state = el[stateKey]; - - state.off && state.off(); - - css(el, 'visibility', !inview && this.hidden ? 'hidden' : ''); - - toggleClass(el, this.inViewClass, inview); - toggleClass(el, state.cls); - - if (/\buk-animation-/.test(state.cls)) { - state.off = once(el, 'animationcancel animationend', function () { return removeClasses(el, 'uk-animation-\\w*'); } - ); - } - - trigger(el, inview ? 'inview' : 'outview'); - - state.inview = inview; - - this.$update(el); - } - - } - - }; - - var scrollspyNav = { - - props: { - cls: String, - closest: String, - scroll: Boolean, - overflow: Boolean, - offset: Number - }, - - data: { - cls: 'uk-active', - closest: false, - scroll: false, - overflow: true, - offset: 0 - }, - - computed: { - - links: { - - get: function(_, $el) { - return $$('a[href^="#"]', $el).filter(function (el) { return el.hash; }); - }, - - watch: function(links) { - if (this.scroll) { - this.$create('scroll', links, {offset: this.offset || 0}); - } - }, - - immediate: true - - }, - - targets: function() { - return $$(this.links.map(function (el) { return escape(el.hash).substr(1); }).join(',')); - }, - - elements: function(ref) { - var selector = ref.closest; - - return closest(this.links, selector || '*'); - } - - }, - - update: [ - - { - - read: function() { - var this$1 = this; - - - var ref = this.targets; - var length = ref.length; - - if (!length || !isVisible(this.$el)) { - return false; - } - - var ref$1 = scrollParents(this.targets, /auto|scroll/, true); - var scrollElement = ref$1[0]; - var scrollTop = scrollElement.scrollTop; - var scrollHeight = scrollElement.scrollHeight; - var max = scrollHeight - getViewportClientHeight(scrollElement); - var active = false; - - if (scrollTop === max) { - active = length - 1; - } else { - - this.targets.every(function (el, i) { - if (offset(el).top - offset(getViewport$1(scrollElement)).top - this$1.offset <= 0) { - active = i; - return true; - } - }); - - if (active === false && this.overflow) { - active = 0; - } - } - - return {active: active}; - }, - - write: function(ref) { - var active = ref.active; - - - var changed = active !== false && !hasClass(this.elements[active], this.cls); - - this.links.forEach(function (el) { return el.blur(); }); - removeClass(this.elements, this.cls); - addClass(this.elements[active], this.cls); - - if (changed) { - trigger(this.$el, 'active', [active, this.elements[active]]); - } - }, - - events: ['scroll', 'resize'] - - } - - ] - - }; - - var sticky = { - - mixins: [Class, Media], - - props: { - top: null, - bottom: Boolean, - offset: String, - animation: String, - clsActive: String, - clsInactive: String, - clsFixed: String, - clsBelow: String, - selTarget: String, - widthElement: Boolean, - showOnUp: Boolean, - targetOffset: Number - }, - - data: { - top: 0, - bottom: false, - offset: 0, - animation: '', - clsActive: 'uk-active', - clsInactive: '', - clsFixed: 'uk-sticky-fixed', - clsBelow: 'uk-sticky-below', - selTarget: '', - widthElement: false, - showOnUp: false, - targetOffset: false - }, - - computed: { - - offset: function(ref) { - var offset = ref.offset; - - return toPx(offset); - }, - - selTarget: function(ref, $el) { - var selTarget = ref.selTarget; - - return selTarget && $(selTarget, $el) || $el; - }, - - widthElement: function(ref, $el) { - var widthElement = ref.widthElement; - - return query(widthElement, $el) || this.placeholder; - }, - - isActive: { - - get: function() { - return hasClass(this.selTarget, this.clsActive); - }, - - set: function(value) { - if (value && !this.isActive) { - replaceClass(this.selTarget, this.clsInactive, this.clsActive); - trigger(this.$el, 'active'); - } else if (!value && !hasClass(this.selTarget, this.clsInactive)) { - replaceClass(this.selTarget, this.clsActive, this.clsInactive); - trigger(this.$el, 'inactive'); - } - } - - } - - }, - - connected: function() { - this.placeholder = $('+ .uk-sticky-placeholder', this.$el) || $('
'); - this.isFixed = false; - this.isActive = false; - }, - - disconnected: function() { - - if (this.isFixed) { - this.hide(); - removeClass(this.selTarget, this.clsInactive); - } - - remove$1(this.placeholder); - this.placeholder = null; - this.widthElement = null; - }, - - events: [ - - { - - name: 'load hashchange popstate', - - el: function() { - return window; - }, - - handler: function() { - var this$1 = this; - - - if (!(this.targetOffset !== false && location.hash && window.pageYOffset > 0)) { - return; - } - - var target = $(location.hash); - - if (target) { - fastdom.read(function () { - - var ref = offset(target); - var top = ref.top; - var elTop = offset(this$1.$el).top; - var elHeight = this$1.$el.offsetHeight; - - if (this$1.isFixed && elTop + elHeight >= top && elTop <= top + target.offsetHeight) { - scrollTop(window, top - elHeight - (isNumeric(this$1.targetOffset) ? this$1.targetOffset : 0) - this$1.offset); - } - - }); - } - - } - - } - - ], - - update: [ - - { - - read: function(ref, types) { - var height = ref.height; - - - this.inactive = !this.matchMedia || !isVisible(this.$el); - - if (this.inactive) { - return false; - } - - if (this.isActive && types.has('resize')) { - this.hide(); - height = this.$el.offsetHeight; - this.show(); - } - - height = !this.isActive ? this.$el.offsetHeight : height; - - this.topOffset = offset(this.isFixed ? this.placeholder : this.$el).top; - this.bottomOffset = this.topOffset + height; - - var bottom = parseProp('bottom', this); - - this.top = Math.max(toFloat(parseProp('top', this)), this.topOffset) - this.offset; - this.bottom = bottom && bottom - this.$el.offsetHeight; - this.width = dimensions(isVisible(this.widthElement) ? this.widthElement : this.$el).width; - - return { - height: height, - top: offsetPosition(this.placeholder)[0], - margins: css(this.$el, ['marginTop', 'marginBottom', 'marginLeft', 'marginRight']) - }; - }, - - write: function(ref) { - var height = ref.height; - var margins = ref.margins; - - - var ref$1 = this; - var placeholder = ref$1.placeholder; - - css(placeholder, assign({height: height}, margins)); - - if (!within(placeholder, document)) { - after(this.$el, placeholder); - placeholder.hidden = true; - } - - this.isActive = !!this.isActive; // force self-assign - - }, - - events: ['resize'] - - }, - - { - - read: function(ref) { - var scroll = ref.scroll; if ( scroll === void 0 ) scroll = 0; - - - this.scroll = window.pageYOffset; - - return { - dir: scroll <= this.scroll ? 'down' : 'up', - scroll: this.scroll - }; - }, - - write: function(data, types) { - var this$1 = this; - - - var now = Date.now(); - var isScrollUpdate = types.has('scroll'); - var initTimestamp = data.initTimestamp; if ( initTimestamp === void 0 ) initTimestamp = 0; - var dir = data.dir; - var lastDir = data.lastDir; - var lastScroll = data.lastScroll; - var scroll = data.scroll; - var top = data.top; - - data.lastScroll = scroll; - - if (scroll < 0 || scroll === lastScroll && isScrollUpdate || this.showOnUp && !isScrollUpdate && !this.isFixed) { - return; - } - - if (now - initTimestamp > 300 || dir !== lastDir) { - data.initScroll = scroll; - data.initTimestamp = now; - } - - data.lastDir = dir; - - if (this.showOnUp && !this.isFixed && Math.abs(data.initScroll - scroll) <= 30 && Math.abs(lastScroll - scroll) <= 10) { - return; - } - - if (this.inactive - || scroll < this.top - || this.showOnUp && (scroll <= this.top || dir === 'down' && isScrollUpdate || dir === 'up' && !this.isFixed && scroll <= this.bottomOffset) - ) { - - if (!this.isFixed) { - - if (Animation.inProgress(this.$el) && top > scroll) { - Animation.cancel(this.$el); - this.hide(); - } - - return; - } - - this.isFixed = false; - - if (this.animation && scroll > this.topOffset) { - Animation.cancel(this.$el); - Animation.out(this.$el, this.animation).then(function () { return this$1.hide(); }, noop); - } else { - this.hide(); - } - - } else if (this.isFixed) { - - this.update(); - - } else if (this.animation) { - - Animation.cancel(this.$el); - this.show(); - Animation.in(this.$el, this.animation).catch(noop); - - } else { - this.show(); - } - - }, - - events: ['resize', 'scroll'] - - } - - ], - - methods: { - - show: function() { - - this.isFixed = true; - this.update(); - this.placeholder.hidden = false; - - }, - - hide: function() { - - this.isActive = false; - removeClass(this.$el, this.clsFixed, this.clsBelow); - css(this.$el, {position: '', top: '', width: ''}); - this.placeholder.hidden = true; - - }, - - update: function() { - - var active = this.top !== 0 || this.scroll > this.top; - var top = Math.max(0, this.offset); - - if (isNumeric(this.bottom) && this.scroll > this.bottom - this.offset) { - top = this.bottom - this.scroll; - } - - css(this.$el, { - position: 'fixed', - top: (top + "px"), - width: this.width - }); - - this.isActive = active; - toggleClass(this.$el, this.clsBelow, this.scroll > this.bottomOffset); - addClass(this.$el, this.clsFixed); - - } - - } - - }; - - function parseProp(prop, ref) { - var $props = ref.$props; - var $el = ref.$el; - var propOffset = ref[(prop + "Offset")]; - - - var value = $props[prop]; - - if (!value) { - return; - } - - if (isString(value) && value.match(/^-?\d/)) { - - return propOffset + toPx(value); - - } else { - - return offset(value === true ? parent($el) : query(value, $el)).bottom; - - } - } - - var Switcher = { - - mixins: [Togglable], - - args: 'connect', - - props: { - connect: String, - toggle: String, - active: Number, - swiping: Boolean - }, - - data: { - connect: '~.uk-switcher', - toggle: '> * > :first-child', - active: 0, - swiping: true, - cls: 'uk-active', - attrItem: 'uk-switcher-item' - }, - - computed: { - - connects: { - - get: function(ref, $el) { - var connect = ref.connect; - - return queryAll(connect, $el); - }, - - watch: function(connects) { - var this$1 = this; - - - if (this.swiping) { - css(connects, 'touch-action', 'pan-y pinch-zoom'); - } - - var index = this.index(); - this.connects.forEach(function (el) { return children(el).forEach(function (child, i) { return toggleClass(child, this$1.cls, i === index); } - ); } - ); - - }, - - immediate: true - - }, - - toggles: { - - get: function(ref, $el) { - var toggle = ref.toggle; - - return $$(toggle, $el).filter(function (el) { return !matches(el, '.uk-disabled *, .uk-disabled, [disabled]'); }); - }, - - watch: function(toggles) { - var active = this.index(); - this.show(~active ? active : toggles[this.active] || toggles[0]); - }, - - immediate: true - - }, - - children: function() { - var this$1 = this; - - return children(this.$el).filter(function (child) { return this$1.toggles.some(function (toggle) { return within(toggle, child); }); }); - } - - }, - - events: [ - - { - - name: 'click', - - delegate: function() { - return this.toggle; - }, - - handler: function(e) { - e.preventDefault(); - this.show(e.current); - } - - }, - - { - name: 'click', - - el: function() { - return this.connects; - }, - - delegate: function() { - return ("[" + (this.attrItem) + "],[data-" + (this.attrItem) + "]"); - }, - - handler: function(e) { - e.preventDefault(); - this.show(data(e.current, this.attrItem)); - } - }, - - { - name: 'swipeRight swipeLeft', - - filter: function() { - return this.swiping; - }, - - el: function() { - return this.connects; - }, - - handler: function(ref) { - var type = ref.type; - - this.show(endsWith(type, 'Left') ? 'next' : 'previous'); - } - } - - ], - - methods: { - - index: function() { - var this$1 = this; - - return findIndex(this.children, function (el) { return hasClass(el, this$1.cls); }); - }, - - show: function(item) { - var this$1 = this; - - - var prev = this.index(); - var next = getIndex( - this.children[getIndex(item, this.toggles, prev)], - children(this.$el) - ); - - if (prev === next) { - return; - } - - this.children.forEach(function (child, i) { - toggleClass(child, this$1.cls, next === i); - attr(this$1.toggles[i], 'aria-expanded', next === i); - }); - - this.connects.forEach(function (ref) { - var children = ref.children; - - return this$1.toggleElement(toNodes(children).filter(function (child) { return hasClass(child, this$1.cls); } - ), false, prev >= 0).then(function () { return this$1.toggleElement(children[next], true, prev >= 0); } - ); - } - ); - } - - } - - }; - - var tab = { - - mixins: [Class], - - extends: Switcher, - - props: { - media: Boolean - }, - - data: { - media: 960, - attrItem: 'uk-tab-item' - }, - - connected: function() { - - var cls = hasClass(this.$el, 'uk-tab-left') - ? 'uk-tab-left' - : hasClass(this.$el, 'uk-tab-right') - ? 'uk-tab-right' - : false; - - if (cls) { - this.$create('toggle', this.$el, {cls: cls, mode: 'media', media: this.media}); - } - } - - }; - - var toggle = { - - mixins: [Media, Togglable], - - args: 'target', - - props: { - href: String, - target: null, - mode: 'list', - queued: Boolean - }, - - data: { - href: false, - target: false, - mode: 'click', - queued: true - }, - - connected: function() { - if (!isFocusable(this.$el)) { - attr(this.$el, 'tabindex', '0'); - } - }, - - computed: { - - target: { - - get: function(ref, $el) { - var href = ref.href; - var target = ref.target; - - target = queryAll(target || href, $el); - return target.length && target || [$el]; - }, - - watch: function() { - this.updateAria(); - }, - - immediate: true - - } - - }, - - events: [ - - { - - name: (pointerEnter + " " + pointerLeave + " focus blur"), - - filter: function() { - return includes(this.mode, 'hover'); - }, - - handler: function(e) { - if (!isTouch(e)) { - this.toggle(("toggle" + (includes([pointerEnter, 'focus'], e.type) ? 'show' : 'hide'))); - } - } - - }, - - { - - name: 'click', - - filter: function() { - return includes(this.mode, 'click') || hasTouch && includes(this.mode, 'hover'); - }, - - handler: function(e) { - - var link; - if (closest(e.target, 'a[href="#"], a[href=""]') - || (link = closest(e.target, 'a[href]')) && ( - !isToggled(this.target, this.cls) - || link.hash && matches(this.target, link.hash) - ) - ) { - e.preventDefault(); - } - - this.toggle(); - } - - }, - - { - - name: 'toggled', - - self: true, - - el: function() { - return this.target; - }, - - handler: function(e, toggled) { - this.updateAria(toggled); - } - } - - ], - - update: { - - read: function() { - return includes(this.mode, 'media') && this.media - ? {match: this.matchMedia} - : false; - }, - - write: function(ref) { - var match = ref.match; - - - var toggled = this.isToggled(this.target); - if (match ? !toggled : toggled) { - this.toggle(); - } - - }, - - events: ['resize'] - - }, - - methods: { - - toggle: function(type) { - var this$1 = this; - - - if (!trigger(this.target, type || 'toggle', [this])) { - return; - } - - if (!this.queued) { - return this.toggleElement(this.target); - } - - var leaving = this.target.filter(function (el) { return hasClass(el, this$1.clsLeave); }); - - if (leaving.length) { - this.target.forEach(function (el) { - var isLeaving = includes(leaving, el); - this$1.toggleElement(el, isLeaving, isLeaving); - }); - return; - } - - var toggled = this.target.filter(this.isToggled); - this.toggleElement(toggled, false).then(function () { return this$1.toggleElement(this$1.target.filter(function (el) { return !includes(toggled, el); } - ), true); } - ); - - }, - - updateAria: function(toggled) { - attr(this.$el, 'aria-expanded', isBoolean(toggled) - ? toggled - : isToggled(this.target, this.cls) - ); - } - - } - - }; - - // TODO improve isToggled handling - function isToggled(target, cls) { - return cls - ? hasClass(target, cls.split(' ')[0]) - : isVisible(target); - } - - var components$1 = /*#__PURE__*/Object.freeze({ - __proto__: null, - Accordion: Accordion, - Alert: alert, - Cover: cover, - Drop: drop, - Dropdown: drop, - FormCustom: formCustom, - Gif: gif, - Grid: grid, - HeightMatch: heightMatch, - HeightViewport: heightViewport, - Icon: Icon, - Img: img, - Leader: leader, - Margin: Margin, - Modal: modal, - Nav: nav, - Navbar: navbar, - Offcanvas: offcanvas, - OverflowAuto: overflowAuto, - Responsive: responsive, - Scroll: scroll, - Scrollspy: scrollspy, - ScrollspyNav: scrollspyNav, - Sticky: sticky, - Svg: SVG, - Switcher: Switcher, - Tab: tab, - Toggle: toggle, - Video: Video, - Close: Close, - Spinner: Spinner, - SlidenavNext: Slidenav, - SlidenavPrevious: Slidenav, - SearchIcon: Search, - Marker: IconComponent, - NavbarToggleIcon: IconComponent, - OverlayIcon: IconComponent, - PaginationNext: IconComponent, - PaginationPrevious: IconComponent, - Totop: IconComponent - }); - - // register components - each(components$1, function (component, name) { return UIkit.component(name, component); } - ); - - // core functionality - UIkit.use(Core); - - boot(UIkit); - - var countdown = { - - mixins: [Class], - - props: { - date: String, - clsWrapper: String - }, - - data: { - date: '', - clsWrapper: '.uk-countdown-%unit%' - }, - - computed: { - - date: function(ref) { - var date = ref.date; - - return Date.parse(date); - }, - - days: function(ref, $el) { - var clsWrapper = ref.clsWrapper; - - return $(clsWrapper.replace('%unit%', 'days'), $el); - }, - - hours: function(ref, $el) { - var clsWrapper = ref.clsWrapper; - - return $(clsWrapper.replace('%unit%', 'hours'), $el); - }, - - minutes: function(ref, $el) { - var clsWrapper = ref.clsWrapper; - - return $(clsWrapper.replace('%unit%', 'minutes'), $el); - }, - - seconds: function(ref, $el) { - var clsWrapper = ref.clsWrapper; - - return $(clsWrapper.replace('%unit%', 'seconds'), $el); - }, - - units: function() { - var this$1 = this; - - return ['days', 'hours', 'minutes', 'seconds'].filter(function (unit) { return this$1[unit]; }); - } - - }, - - connected: function() { - this.start(); - }, - - disconnected: function() { - var this$1 = this; - - this.stop(); - this.units.forEach(function (unit) { return empty(this$1[unit]); }); - }, - - events: [ - - { - - name: 'visibilitychange', - - el: function() { - return document; - }, - - handler: function() { - if (document.hidden) { - this.stop(); - } else { - this.start(); - } - } - - } - - ], - - update: { - - write: function() { - var this$1 = this; - - - var timespan = getTimeSpan(this.date); - - if (timespan.total <= 0) { - - this.stop(); - - timespan.days - = timespan.hours - = timespan.minutes - = timespan.seconds - = 0; - } - - this.units.forEach(function (unit) { - - var digits = String(Math.floor(timespan[unit])); - - digits = digits.length < 2 ? ("0" + digits) : digits; - - var el = this$1[unit]; - if (el.textContent !== digits) { - digits = digits.split(''); - - if (digits.length !== el.children.length) { - html(el, digits.map(function () { return ''; }).join('')); - } - - digits.forEach(function (digit, i) { return el.children[i].textContent = digit; }); - } - - }); - - } - - }, - - methods: { - - start: function() { - - this.stop(); - - if (this.date && this.units.length) { - this.$update(); - this.timer = setInterval(this.$update, 1000); - } - - }, - - stop: function() { - - if (this.timer) { - clearInterval(this.timer); - this.timer = null; - } - - } - - } - - }; - - function getTimeSpan(date) { - - var total = date - Date.now(); - - return { - total: total, - seconds: total / 1000 % 60, - minutes: total / 1000 / 60 % 60, - hours: total / 1000 / 60 / 60 % 24, - days: total / 1000 / 60 / 60 / 24 - }; - } - - var clsLeave = 'uk-transition-leave'; - var clsEnter = 'uk-transition-enter'; - - function fade(action, target, duration, stagger) { - if ( stagger === void 0 ) stagger = 0; - - - var index = transitionIndex(target, true); - var propsIn = {opacity: 1}; - var propsOut = {opacity: 0}; - - var wrapIndexFn = function (fn) { return function () { return index === transitionIndex(target) ? fn() : Promise$1.reject(); }; }; - - var leaveFn = wrapIndexFn(function () { - - addClass(target, clsLeave); - - return Promise$1.all(getTransitionNodes(target).map(function (child, i) { return new Promise$1(function (resolve) { return setTimeout(function () { return Transition.start(child, propsOut, duration / 2, 'ease').then(resolve); }, i * stagger); } - ); } - )).then(function () { return removeClass(target, clsLeave); }); - - }); - - var enterFn = wrapIndexFn(function () { - - var oldHeight = height(target); - - addClass(target, clsEnter); - action(); - - css(children(target), {opacity: 0}); - - // Ensure UIkit updates have propagated - return new Promise$1(function (resolve) { return requestAnimationFrame(function () { - - var nodes = children(target); - var newHeight = height(target); - - // Ensure Grid cells do not stretch when height is applied - css(target, 'alignContent', 'flex-start'); - height(target, oldHeight); - - var transitionNodes = getTransitionNodes(target); - css(nodes, propsOut); - - var transitions = transitionNodes.map(function (child, i) { return new Promise$1(function (resolve) { return setTimeout(function () { return Transition.start(child, propsIn, duration / 2, 'ease').then(resolve); }, i * stagger); } - ); } - ); - - if (oldHeight !== newHeight) { - transitions.push(Transition.start(target, {height: newHeight}, duration / 2 + transitionNodes.length * stagger, 'ease')); - } - - Promise$1.all(transitions).then(function () { - removeClass(target, clsEnter); - if (index === transitionIndex(target)) { - css(target, {height: '', alignContent: ''}); - css(nodes, {opacity: ''}); - delete target.dataset.transition; - } - resolve(); - }); - }); } - ); - }); - - return hasClass(target, clsLeave) - ? waitTransitionend(target).then(enterFn) - : hasClass(target, clsEnter) - ? waitTransitionend(target).then(leaveFn).then(enterFn) - : leaveFn().then(enterFn); - } - - function transitionIndex(target, next) { - if (next) { - target.dataset.transition = 1 + transitionIndex(target); - } - - return toNumber(target.dataset.transition) || 0; - } - - function waitTransitionend(target) { - return Promise$1.all(children(target).filter(Transition.inProgress).map(function (el) { return new Promise$1(function (resolve) { return once(el, 'transitionend transitioncanceled', resolve); }); } - )); - } - - function getTransitionNodes(target) { - return getRows(children(target)).reduce(function (nodes, row) { return nodes.concat(sortBy$1(row.filter(function (el) { return isInView(el); }), 'offsetLeft')); }, []); - } - - function slide (action, target, duration) { - - return new Promise$1(function (resolve) { return requestAnimationFrame(function () { - - var nodes = children(target); - - // Get current state - var currentProps = nodes.map(function (el) { return getProps(el, true); }); - var targetProps = css(target, ['height', 'padding']); - - // Cancel previous animations - Transition.cancel(target); - nodes.forEach(Transition.cancel); - reset(target); - - // Adding, sorting, removing nodes - action(); - - // Find new nodes - nodes = nodes.concat(children(target).filter(function (el) { return !includes(nodes, el); })); - - // Wait for update to propagate - Promise$1.resolve().then(function () { - - // Force update - fastdom.flush(); - - // Get new state - var targetPropsTo = css(target, ['height', 'padding']); - var ref = getTransitionProps(target, nodes, currentProps); - var propsTo = ref[0]; - var propsFrom = ref[1]; - - // Reset to previous state - nodes.forEach(function (el, i) { return propsFrom[i] && css(el, propsFrom[i]); }); - css(target, assign({display: 'block'}, targetProps)); - - // Start transitions on next frame - requestAnimationFrame(function () { - - var transitions = nodes.map(function (el, i) { return parent(el) === target && Transition.start(el, propsTo[i], duration, 'ease'); } - ).concat(Transition.start(target, targetPropsTo, duration, 'ease')); - - Promise$1.all(transitions).then(function () { - nodes.forEach(function (el, i) { return parent(el) === target && css(el, 'display', propsTo[i].opacity === 0 ? 'none' : ''); }); - reset(target); - }, noop).then(resolve); - - }); - }); - }); }); - } - - function getProps(el, opacity) { - - var zIndex = css(el, 'zIndex'); - - return isVisible(el) - ? assign({ - display: '', - opacity: opacity ? css(el, 'opacity') : '0', - pointerEvents: 'none', - position: 'absolute', - zIndex: zIndex === 'auto' ? index(el) : zIndex - }, getPositionWithMargin(el)) - : false; - } - - function getTransitionProps(target, nodes, currentProps) { - - var propsTo = nodes.map(function (el, i) { return parent(el) && i in currentProps - ? currentProps[i] - ? isVisible(el) - ? getPositionWithMargin(el) - : {opacity: 0} - : {opacity: isVisible(el) ? 1 : 0} - : false; }); - - var propsFrom = propsTo.map(function (props, i) { - - var from = parent(nodes[i]) === target && (currentProps[i] || getProps(nodes[i])); - - if (!from) { - return false; - } - - if (!props) { - delete from.opacity; - } else if (!('opacity' in props)) { - var opacity = from.opacity; - - if (opacity % 1) { - props.opacity = 1; - } else { - delete from.opacity; - } - } - - return from; - }); - - return [propsTo, propsFrom]; - } - - function reset(el) { - css(el.children, { - height: '', - left: '', - opacity: '', - pointerEvents: '', - position: '', - top: '', - marginTop: '', - marginLeft: '', - transform: '', - width: '', - zIndex: '' - }); - css(el, {height: '', display: '', padding: ''}); - } - - function getPositionWithMargin(el) { - var ref = offset(el); - var height = ref.height; - var width = ref.width; - var ref$1 = position(el); - var top = ref$1.top; - var left = ref$1.left; - var ref$2 = css(el, ['marginTop', 'marginLeft']); - var marginLeft = ref$2.marginLeft; - var marginTop = ref$2.marginTop; - - return {top: top, left: left, height: height, width: width, marginLeft: marginLeft, marginTop: marginTop, transform: ''}; - } - - var Animate = { - - props: { - duration: Number, - animation: Boolean - }, - - data: { - duration: 150, - animation: 'slide' - }, - - methods: { - - animate: function(action, target) { - var this$1 = this; - if ( target === void 0 ) target = this.$el; - - - var name = this.animation; - var animationFn = name === 'fade' - ? fade - : name === 'delayed-fade' - ? function () { - var args = [], len = arguments.length; - while ( len-- ) args[ len ] = arguments[ len ]; - - return fade.apply(void 0, args.concat( [40] )); - } - : !name - ? function () { - action(); - return Promise$1.resolve(); - } - : slide; - - return animationFn(action, target, this.duration) - .then(function () { return this$1.$update(target, 'resize'); }, noop); - } - - } - }; - - var filter = { - - mixins: [Animate], - - args: 'target', - - props: { - target: Boolean, - selActive: Boolean - }, - - data: { - target: null, - selActive: false, - attrItem: 'uk-filter-control', - cls: 'uk-active', - duration: 250 - }, - - computed: { - - toggles: { - - get: function(ref, $el) { - var attrItem = ref.attrItem; - - return $$(("[" + attrItem + "],[data-" + attrItem + "]"), $el); - }, - - watch: function() { - var this$1 = this; - - - this.updateState(); - - if (this.selActive !== false) { - var actives = $$(this.selActive, this.$el); - this.toggles.forEach(function (el) { return toggleClass(el, this$1.cls, includes(actives, el)); }); - } - - }, - - immediate: true - - }, - - children: { - - get: function(ref, $el) { - var target = ref.target; - - return $$((target + " > *"), $el); - }, - - watch: function(list, old) { - if (old && !isEqualList(list, old)) { - this.updateState(); - } - }, - - immediate: true - - } - - }, - - events: [ - - { - - name: 'click', - - delegate: function() { - return ("[" + (this.attrItem) + "],[data-" + (this.attrItem) + "]"); - }, - - handler: function(e) { - - e.preventDefault(); - this.apply(e.current); - - } - - } - - ], - - methods: { - - apply: function(el) { - var prevState = this.getState(); - var newState = mergeState(el, this.attrItem, this.getState()); - - if (!isEqualState(prevState, newState)) { - this.setState(newState); - } - }, - - getState: function() { - var this$1 = this; - - return this.toggles - .filter(function (item) { return hasClass(item, this$1.cls); }) - .reduce(function (state, el) { return mergeState(el, this$1.attrItem, state); }, {filter: {'': ''}, sort: []}); - }, - - setState: function(state, animate) { - var this$1 = this; - if ( animate === void 0 ) animate = true; - - - state = assign({filter: {'': ''}, sort: []}, state); - - trigger(this.$el, 'beforeFilter', [this, state]); - - this.toggles.forEach(function (el) { return toggleClass(el, this$1.cls, !!matchFilter(el, this$1.attrItem, state)); }); - - Promise$1.all($$(this.target, this.$el).map(function (target) { - var filterFn = function () { - applyState(state, target, children(target)); - this$1.$update(this$1.$el); - }; - return animate ? this$1.animate(filterFn, target) : filterFn(); - })).then(function () { return trigger(this$1.$el, 'afterFilter', [this$1]); }); - - }, - - updateState: function() { - var this$1 = this; - - fastdom.write(function () { return this$1.setState(this$1.getState(), false); }); - } - - } - - }; - - function getFilter(el, attr) { - return parseOptions(data(el, attr), ['filter']); - } - - function isEqualState(stateA, stateB) { - return ['filter', 'sort'].every(function (prop) { return isEqual(stateA[prop], stateB[prop]); }); - } - - function applyState(state, target, children) { - var selector = getSelector(state); - - children.forEach(function (el) { return css(el, 'display', selector && !matches(el, selector) ? 'none' : ''); }); - - var ref = state.sort; - var sort = ref[0]; - var order = ref[1]; - - if (sort) { - var sorted = sortItems(children, sort, order); - if (!isEqual(sorted, children)) { - append(target, sorted); - } - } - } - - function mergeState(el, attr, state) { - - var filterBy = getFilter(el, attr); - var filter = filterBy.filter; - var group = filterBy.group; - var sort = filterBy.sort; - var order = filterBy.order; if ( order === void 0 ) order = 'asc'; - - if (filter || isUndefined(sort)) { - - if (group) { - - if (filter) { - delete state.filter['']; - state.filter[group] = filter; - } else { - delete state.filter[group]; - - if (isEmpty(state.filter) || '' in state.filter) { - state.filter = {'': filter || ''}; - } - - } - - } else { - state.filter = {'': filter || ''}; - } - - } - - if (!isUndefined(sort)) { - state.sort = [sort, order]; - } - - return state; - } - - function matchFilter(el, attr, ref) { - var stateFilter = ref.filter; if ( stateFilter === void 0 ) stateFilter = {'': ''}; - var ref_sort = ref.sort; - var stateSort = ref_sort[0]; - var stateOrder = ref_sort[1]; - - - var ref$1 = getFilter(el, attr); - var filter = ref$1.filter; if ( filter === void 0 ) filter = ''; - var group = ref$1.group; if ( group === void 0 ) group = ''; - var sort = ref$1.sort; - var order = ref$1.order; if ( order === void 0 ) order = 'asc'; - - return isUndefined(sort) - ? group in stateFilter && filter === stateFilter[group] - || !filter && group && !(group in stateFilter) && !stateFilter[''] - : stateSort === sort && stateOrder === order; - } - - function isEqualList(listA, listB) { - return listA.length === listB.length - && listA.every(function (el) { return ~listB.indexOf(el); }); - } - - function getSelector(ref) { - var filter = ref.filter; - - var selector = ''; - each(filter, function (value) { return selector += value || ''; }); - return selector; - } - - function sortItems(nodes, sort, order) { - return assign([], nodes).sort(function (a, b) { return data(a, sort).localeCompare(data(b, sort), undefined, {numeric: true}) * (order === 'asc' || -1); }); - } - - var Animations$2 = { - - slide: { - - show: function(dir) { - return [ - {transform: translate(dir * -100)}, - {transform: translate()} - ]; - }, - - percent: function(current) { - return translated(current); - }, - - translate: function(percent, dir) { - return [ - {transform: translate(dir * -100 * percent)}, - {transform: translate(dir * 100 * (1 - percent))} - ]; - } - - } - - }; - - function translated(el) { - return Math.abs(css(el, 'transform').split(',')[4] / el.offsetWidth) || 0; - } - - function translate(value, unit) { - if ( value === void 0 ) value = 0; - if ( unit === void 0 ) unit = '%'; - - value += value ? unit : ''; - return isIE ? ("translateX(" + value + ")") : ("translate3d(" + value + ", 0, 0)"); // currently not translate3d in IE, translate3d within translate3d does not work while transitioning - } - - function scale3d(value) { - return ("scale3d(" + value + ", " + value + ", 1)"); - } - - var Animations$1 = assign({}, Animations$2, { - - fade: { - - show: function() { - return [ - {opacity: 0}, - {opacity: 1} - ]; - }, - - percent: function(current) { - return 1 - css(current, 'opacity'); - }, - - translate: function(percent) { - return [ - {opacity: 1 - percent}, - {opacity: percent} - ]; - } - - }, - - scale: { - - show: function() { - return [ - {opacity: 0, transform: scale3d(1 - .2)}, - {opacity: 1, transform: scale3d(1)} - ]; - }, - - percent: function(current) { - return 1 - css(current, 'opacity'); - }, - - translate: function(percent) { - return [ - {opacity: 1 - percent, transform: scale3d(1 - .2 * percent)}, - {opacity: percent, transform: scale3d(1 - .2 + .2 * percent)} - ]; - } - - } - - }); - - function Transitioner$1(prev, next, dir, ref) { - var animation = ref.animation; - var easing = ref.easing; - - - var percent = animation.percent; - var translate = animation.translate; - var show = animation.show; if ( show === void 0 ) show = noop; - var props = show(dir); - var deferred = new Deferred(); - - return { - - dir: dir, - - show: function(duration, percent, linear) { - var this$1 = this; - if ( percent === void 0 ) percent = 0; - - - var timing = linear ? 'linear' : easing; - duration -= Math.round(duration * clamp(percent, -1, 1)); - - this.translate(percent); - - triggerUpdate$1(next, 'itemin', {percent: percent, duration: duration, timing: timing, dir: dir}); - triggerUpdate$1(prev, 'itemout', {percent: 1 - percent, duration: duration, timing: timing, dir: dir}); - - Promise$1.all([ - Transition.start(next, props[1], duration, timing), - Transition.start(prev, props[0], duration, timing) - ]).then(function () { - this$1.reset(); - deferred.resolve(); - }, noop); - - return deferred.promise; - }, - - cancel: function() { - Transition.cancel([next, prev]); - }, - - reset: function() { - for (var prop in props[0]) { - css([next, prev], prop, ''); - } - }, - - forward: function(duration, percent) { - if ( percent === void 0 ) percent = this.percent(); - - Transition.cancel([next, prev]); - return this.show(duration, percent, true); - }, - - translate: function(percent) { - - this.reset(); - - var props = translate(percent, dir); - css(next, props[1]); - css(prev, props[0]); - triggerUpdate$1(next, 'itemtranslatein', {percent: percent, dir: dir}); - triggerUpdate$1(prev, 'itemtranslateout', {percent: 1 - percent, dir: dir}); - - }, - - percent: function() { - return percent(prev || next, next, dir); - }, - - getDistance: function() { - return prev && prev.offsetWidth; - } - - }; - - } - - function triggerUpdate$1(el, type, data) { - trigger(el, createEvent(type, false, false, data)); - } - - var SliderAutoplay = { - - props: { - autoplay: Boolean, - autoplayInterval: Number, - pauseOnHover: Boolean - }, - - data: { - autoplay: false, - autoplayInterval: 7000, - pauseOnHover: true - }, - - connected: function() { - this.autoplay && this.startAutoplay(); - }, - - disconnected: function() { - this.stopAutoplay(); - }, - - update: function() { - attr(this.slides, 'tabindex', '-1'); - }, - - events: [ - - { - - name: 'visibilitychange', - - el: function() { - return document; - }, - - filter: function() { - return this.autoplay; - }, - - handler: function() { - if (document.hidden) { - this.stopAutoplay(); - } else { - this.startAutoplay(); - } - } - - } - - ], - - methods: { - - startAutoplay: function() { - var this$1 = this; - - - this.stopAutoplay(); - - this.interval = setInterval( - function () { return (!this$1.draggable || !$(':focus', this$1.$el)) - && (!this$1.pauseOnHover || !matches(this$1.$el, ':hover')) - && !this$1.stack.length - && this$1.show('next'); }, - this.autoplayInterval - ); - - }, - - stopAutoplay: function() { - this.interval && clearInterval(this.interval); - } - - } - - }; - - var SliderDrag = { - - props: { - draggable: Boolean - }, - - data: { - draggable: true, - threshold: 10 - }, - - created: function() { - var this$1 = this; - - - ['start', 'move', 'end'].forEach(function (key) { - - var fn = this$1[key]; - this$1[key] = function (e) { - - var pos = getEventPos(e).x * (isRtl ? -1 : 1); - - this$1.prevPos = pos !== this$1.pos ? this$1.pos : this$1.prevPos; - this$1.pos = pos; - - fn(e); - }; - - }); - - }, - - events: [ - - { - - name: pointerDown, - - delegate: function() { - return this.selSlides; - }, - - handler: function(e) { - - if (!this.draggable - || !isTouch(e) && hasTextNodesOnly(e.target) - || closest(e.target, selInput) - || e.button > 0 - || this.length < 2 - ) { - return; - } - - this.start(e); - } - - }, - - { - name: 'dragstart', - - handler: function(e) { - e.preventDefault(); - } - } - - ], - - methods: { - - start: function() { - - this.drag = this.pos; - - if (this._transitioner) { - - this.percent = this._transitioner.percent(); - this.drag += this._transitioner.getDistance() * this.percent * this.dir; - - this._transitioner.cancel(); - this._transitioner.translate(this.percent); - - this.dragging = true; - - this.stack = []; - - } else { - this.prevIndex = this.index; - } - - on(document, pointerMove, this.move, {passive: false}); - - // 'input' event is triggered by video controls - on(document, (pointerUp + " " + pointerCancel + " input"), this.end, true); - - css(this.list, 'userSelect', 'none'); - - }, - - move: function(e) { - var this$1 = this; - - - var distance = this.pos - this.drag; - - if (distance === 0 || this.prevPos === this.pos || !this.dragging && Math.abs(distance) < this.threshold) { - return; - } - - // prevent click event - css(this.list, 'pointerEvents', 'none'); - - e.cancelable && e.preventDefault(); - - this.dragging = true; - this.dir = (distance < 0 ? 1 : -1); - - var ref = this; - var slides = ref.slides; - var ref$1 = this; - var prevIndex = ref$1.prevIndex; - var dis = Math.abs(distance); - var nextIndex = this.getIndex(prevIndex + this.dir, prevIndex); - var width = this._getDistance(prevIndex, nextIndex) || slides[prevIndex].offsetWidth; - - while (nextIndex !== prevIndex && dis > width) { - - this.drag -= width * this.dir; - - prevIndex = nextIndex; - dis -= width; - nextIndex = this.getIndex(prevIndex + this.dir, prevIndex); - width = this._getDistance(prevIndex, nextIndex) || slides[prevIndex].offsetWidth; - - } - - this.percent = dis / width; - - var prev = slides[prevIndex]; - var next = slides[nextIndex]; - var changed = this.index !== nextIndex; - var edge = prevIndex === nextIndex; - - var itemShown; - - [this.index, this.prevIndex].filter(function (i) { return !includes([nextIndex, prevIndex], i); }).forEach(function (i) { - trigger(slides[i], 'itemhidden', [this$1]); - - if (edge) { - itemShown = true; - this$1.prevIndex = prevIndex; - } - - }); - - if (this.index === prevIndex && this.prevIndex !== prevIndex || itemShown) { - trigger(slides[this.index], 'itemshown', [this]); - } - - if (changed) { - this.prevIndex = prevIndex; - this.index = nextIndex; - - !edge && trigger(prev, 'beforeitemhide', [this]); - trigger(next, 'beforeitemshow', [this]); - } - - this._transitioner = this._translate(Math.abs(this.percent), prev, !edge && next); - - if (changed) { - !edge && trigger(prev, 'itemhide', [this]); - trigger(next, 'itemshow', [this]); - } - - }, - - end: function() { - - off(document, pointerMove, this.move, {passive: false}); - off(document, (pointerUp + " " + pointerCancel + " input"), this.end, true); - - if (this.dragging) { - - this.dragging = null; - - if (this.index === this.prevIndex) { - this.percent = 1 - this.percent; - this.dir *= -1; - this._show(false, this.index, true); - this._transitioner = null; - } else { - - var dirChange = (isRtl ? this.dir * (isRtl ? 1 : -1) : this.dir) < 0 === this.prevPos > this.pos; - this.index = dirChange ? this.index : this.prevIndex; - - if (dirChange) { - this.percent = 1 - this.percent; - } - - this.show(this.dir > 0 && !dirChange || this.dir < 0 && dirChange ? 'next' : 'previous', true); - } - - } - - css(this.list, {userSelect: '', pointerEvents: ''}); - - this.drag - = this.percent - = null; - - } - - } - - }; - - function hasTextNodesOnly(el) { - return !el.children.length && el.childNodes.length; - } - - var SliderNav = { - - data: { - selNav: false - }, - - computed: { - - nav: function(ref, $el) { - var selNav = ref.selNav; - - return $(selNav, $el); - }, - - selNavItem: function(ref) { - var attrItem = ref.attrItem; - - return ("[" + attrItem + "],[data-" + attrItem + "]"); - }, - - navItems: function(_, $el) { - return $$(this.selNavItem, $el); - } - - }, - - update: { - - write: function() { - var this$1 = this; - - - if (this.nav && this.length !== this.nav.children.length) { - html(this.nav, this.slides.map(function (_, i) { return ("
  • "); }).join('')); - } - - this.navItems.concat(this.nav).forEach(function (el) { return el && (el.hidden = !this$1.maxIndex); }); - - this.updateNav(); - - }, - - events: ['resize'] - - }, - - events: [ - - { - - name: 'click', - - delegate: function() { - return this.selNavItem; - }, - - handler: function(e) { - e.preventDefault(); - this.show(data(e.current, this.attrItem)); - } - - }, - - { - - name: 'itemshow', - handler: 'updateNav' - - } - - ], - - methods: { - - updateNav: function() { - var this$1 = this; - - - var i = this.getValidIndex(); - this.navItems.forEach(function (el) { - - var cmd = data(el, this$1.attrItem); - - toggleClass(el, this$1.clsActive, toNumber(cmd) === i); - toggleClass(el, 'uk-invisible', this$1.finite && (cmd === 'previous' && i === 0 || cmd === 'next' && i >= this$1.maxIndex)); - }); - - } - - } - - }; - - var Slider = { - - mixins: [SliderAutoplay, SliderDrag, SliderNav], - - props: { - clsActivated: Boolean, - easing: String, - index: Number, - finite: Boolean, - velocity: Number, - selSlides: String - }, - - data: function () { return ({ - easing: 'ease', - finite: false, - velocity: 1, - index: 0, - prevIndex: -1, - stack: [], - percent: 0, - clsActive: 'uk-active', - clsActivated: false, - Transitioner: false, - transitionOptions: {} - }); }, - - connected: function() { - this.prevIndex = -1; - this.index = this.getValidIndex(this.$props.index); - this.stack = []; - }, - - disconnected: function() { - removeClass(this.slides, this.clsActive); - }, - - computed: { - - duration: function(ref, $el) { - var velocity = ref.velocity; - - return speedUp($el.offsetWidth / velocity); - }, - - list: function(ref, $el) { - var selList = ref.selList; - - return $(selList, $el); - }, - - maxIndex: function() { - return this.length - 1; - }, - - selSlides: function(ref) { - var selList = ref.selList; - var selSlides = ref.selSlides; - - return (selList + " " + (selSlides || '> *')); - }, - - slides: { - - get: function() { - return $$(this.selSlides, this.$el); - }, - - watch: function() { - this.$reset(); - } - - }, - - length: function() { - return this.slides.length; - } - - }, - - events: { - - itemshown: function() { - this.$update(this.list); - } - - }, - - methods: { - - show: function(index, force) { - var this$1 = this; - if ( force === void 0 ) force = false; - - - if (this.dragging || !this.length) { - return; - } - - var ref = this; - var stack = ref.stack; - var queueIndex = force ? 0 : stack.length; - var reset = function () { - stack.splice(queueIndex, 1); - - if (stack.length) { - this$1.show(stack.shift(), true); - } - }; - - stack[force ? 'unshift' : 'push'](index); - - if (!force && stack.length > 1) { - - if (stack.length === 2) { - this._transitioner.forward(Math.min(this.duration, 200)); - } - - return; - } - - var prevIndex = this.getIndex(this.index); - var prev = hasClass(this.slides, this.clsActive) && this.slides[prevIndex]; - var nextIndex = this.getIndex(index, this.index); - var next = this.slides[nextIndex]; - - if (prev === next) { - reset(); - return; - } - - this.dir = getDirection(index, prevIndex); - this.prevIndex = prevIndex; - this.index = nextIndex; - - if (prev && !trigger(prev, 'beforeitemhide', [this]) - || !trigger(next, 'beforeitemshow', [this, prev]) - ) { - this.index = this.prevIndex; - reset(); - return; - } - - var promise = this._show(prev, next, force).then(function () { - - prev && trigger(prev, 'itemhidden', [this$1]); - trigger(next, 'itemshown', [this$1]); - - return new Promise$1(function (resolve) { - fastdom.write(function () { - stack.shift(); - if (stack.length) { - this$1.show(stack.shift(), true); - } else { - this$1._transitioner = null; - } - resolve(); - }); - }); - - }); - - prev && trigger(prev, 'itemhide', [this]); - trigger(next, 'itemshow', [this]); - - return promise; - - }, - - getIndex: function(index, prev) { - if ( index === void 0 ) index = this.index; - if ( prev === void 0 ) prev = this.index; - - return clamp(getIndex(index, this.slides, prev, this.finite), 0, this.maxIndex); - }, - - getValidIndex: function(index, prevIndex) { - if ( index === void 0 ) index = this.index; - if ( prevIndex === void 0 ) prevIndex = this.prevIndex; - - return this.getIndex(index, prevIndex); - }, - - _show: function(prev, next, force) { - - this._transitioner = this._getTransitioner( - prev, - next, - this.dir, - assign({ - easing: force - ? next.offsetWidth < 600 - ? 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' /* easeOutQuad */ - : 'cubic-bezier(0.165, 0.84, 0.44, 1)' /* easeOutQuart */ - : this.easing - }, this.transitionOptions) - ); - - if (!force && !prev) { - this._translate(1); - return Promise$1.resolve(); - } - - var ref = this.stack; - var length = ref.length; - return this._transitioner[length > 1 ? 'forward' : 'show'](length > 1 ? Math.min(this.duration, 75 + 75 / (length - 1)) : this.duration, this.percent); - - }, - - _getDistance: function(prev, next) { - return this._getTransitioner(prev, prev !== next && next).getDistance(); - }, - - _translate: function(percent, prev, next) { - if ( prev === void 0 ) prev = this.prevIndex; - if ( next === void 0 ) next = this.index; - - var transitioner = this._getTransitioner(prev !== next ? prev : false, next); - transitioner.translate(percent); - return transitioner; - }, - - _getTransitioner: function(prev, next, dir, options) { - if ( prev === void 0 ) prev = this.prevIndex; - if ( next === void 0 ) next = this.index; - if ( dir === void 0 ) dir = this.dir || 1; - if ( options === void 0 ) options = this.transitionOptions; - - return new this.Transitioner( - isNumber(prev) ? this.slides[prev] : prev, - isNumber(next) ? this.slides[next] : next, - dir * (isRtl ? -1 : 1), - options - ); - } - - } - - }; - - function getDirection(index, prevIndex) { - return index === 'next' - ? 1 - : index === 'previous' - ? -1 - : index < prevIndex - ? -1 - : 1; - } - - function speedUp(x) { - return .5 * x + 300; // parabola through (400,500; 600,600; 1800,1200) - } - - var Slideshow = { - - mixins: [Slider], - - props: { - animation: String - }, - - data: { - animation: 'slide', - clsActivated: 'uk-transition-active', - Animations: Animations$2, - Transitioner: Transitioner$1 - }, - - computed: { - - animation: function(ref) { - var animation = ref.animation; - var Animations = ref.Animations; - - return assign(Animations[animation] || Animations.slide, {name: animation}); - }, - - transitionOptions: function() { - return {animation: this.animation}; - } - - }, - - events: { - - 'itemshow itemhide itemshown itemhidden': function(ref) { - var target = ref.target; - - this.$update(target); - }, - - beforeitemshow: function(ref) { - var target = ref.target; - - addClass(target, this.clsActive); - }, - - itemshown: function(ref) { - var target = ref.target; - - addClass(target, this.clsActivated); - }, - - itemhidden: function(ref) { - var target = ref.target; - - removeClass(target, this.clsActive, this.clsActivated); - } - - } - - }; - - var LightboxPanel = { - - mixins: [Container, Modal, Togglable, Slideshow], - - functional: true, - - props: { - delayControls: Number, - preload: Number, - videoAutoplay: Boolean, - template: String - }, - - data: function () { return ({ - preload: 1, - videoAutoplay: false, - delayControls: 3000, - items: [], - cls: 'uk-open', - clsPage: 'uk-lightbox-page', - selList: '.uk-lightbox-items', - attrItem: 'uk-lightbox-item', - selClose: '.uk-close-large', - selCaption: '.uk-lightbox-caption', - pauseOnHover: false, - velocity: 2, - Animations: Animations$1, - template: "
      " - }); }, - - created: function() { - - var $el = $(this.template); - var list = $(this.selList, $el); - this.items.forEach(function () { return append(list, '
    • '); }); - - this.$mount(append(this.container, $el)); - - }, - - computed: { - - caption: function(ref, $el) { - var selCaption = ref.selCaption; - - return $(selCaption, $el); - } - - }, - - events: [ - - { - - name: (pointerMove + " " + pointerDown + " keydown"), - - handler: 'showControls' - - }, - - { - - name: 'click', - - self: true, - - delegate: function() { - return this.selSlides; - }, - - handler: function(e) { - - if (e.defaultPrevented) { - return; - } - - this.hide(); - } - - }, - - { - - name: 'shown', - - self: true, - - handler: function() { - this.showControls(); - } - - }, - - { - - name: 'hide', - - self: true, - - handler: function() { - - this.hideControls(); - - removeClass(this.slides, this.clsActive); - Transition.stop(this.slides); - - } - }, - - { - - name: 'hidden', - - self: true, - - handler: function() { - this.$destroy(true); - } - - }, - - { - - name: 'keyup', - - el: function() { - return document; - }, - - handler: function(e) { - - if (!this.isToggled(this.$el) || !this.draggable) { - return; - } - - switch (e.keyCode) { - case 37: - this.show('previous'); - break; - case 39: - this.show('next'); - break; - } - } - }, - - { - - name: 'beforeitemshow', - - handler: function(e) { - - if (this.isToggled()) { - return; - } - - this.draggable = false; - - e.preventDefault(); - - this.toggleElement(this.$el, true, false); - - this.animation = Animations$1['scale']; - removeClass(e.target, this.clsActive); - this.stack.splice(1, 0, this.index); - - } - - }, - - { - - name: 'itemshow', - - handler: function() { - - html(this.caption, this.getItem().caption || ''); - - for (var j = -this.preload; j <= this.preload; j++) { - this.loadItem(this.index + j); - } - - } - - }, - - { - - name: 'itemshown', - - handler: function() { - this.draggable = this.$props.draggable; - } - - }, - - { - - name: 'itemload', - - handler: function(_, item) { - var this$1 = this; - - - var src = item.source; - var type = item.type; - var alt = item.alt; if ( alt === void 0 ) alt = ''; - var poster = item.poster; - var attrs = item.attrs; if ( attrs === void 0 ) attrs = {}; - - this.setItem(item, ''); - - if (!src) { - return; - } - - var matches; - var iframeAttrs = { - frameborder: '0', - allow: 'autoplay', - allowfullscreen: '', - style: 'max-width: 100%; box-sizing: border-box;', - 'uk-responsive': '', - 'uk-video': ("" + (this.videoAutoplay)) - }; - - // Image - if (type === 'image' || src.match(/\.(avif|jpe?g|a?png|gif|svg|webp)($|\?)/i)) { - - getImage(src, attrs.srcset, attrs.size).then( - function (ref) { - var width = ref.width; - var height = ref.height; - - return this$1.setItem(item, createEl('img', assign({src: src, width: width, height: height, alt: alt}, attrs))); - }, - function () { return this$1.setError(item); } - ); - - // Video - } else if (type === 'video' || src.match(/\.(mp4|webm|ogv)($|\?)/i)) { - - var video = createEl('video', assign({ - src: src, - poster: poster, - controls: '', - playsinline: '', - 'uk-video': ("" + (this.videoAutoplay)) - }, attrs)); - - on(video, 'loadedmetadata', function () { - attr(video, {width: video.videoWidth, height: video.videoHeight}); - this$1.setItem(item, video); - }); - on(video, 'error', function () { return this$1.setError(item); }); - - // Iframe - } else if (type === 'iframe' || src.match(/\.(html|php)($|\?)/i)) { - - this.setItem(item, createEl('iframe', assign({ - src: src, - frameborder: '0', - allowfullscreen: '', - class: 'uk-lightbox-iframe' - }, attrs))); - - // YouTube - } else if ((matches = src.match(/\/\/(?:.*?youtube(-nocookie)?\..*?[?&]v=|youtu\.be\/)([\w-]{11})[&?]?(.*)?/))) { - - this.setItem(item, createEl('iframe', assign({ - src: ("https://www.youtube/" + (matches[1] || '') + ".com/embed/" + (matches[2]) + (matches[3] ? ("?" + (matches[3])) : '')), - width: 1920, - height: 1080 - }, iframeAttrs, attrs))); - - // Vimeo - } else if ((matches = src.match(/\/\/.*?vimeo\.[a-z]+\/(\d+)[&?]?(.*)?/))) { - - ajax(("https://vimeo.com/api/oembed.json?maxwidth=1920&url=" + (encodeURI(src))), { - responseType: 'json', - withCredentials: false - }).then( - function (ref) { - var ref_response = ref.response; - var height = ref_response.height; - var width = ref_response.width; - - return this$1.setItem(item, createEl('iframe', assign({ - src: ("https://player.vimeo.com/video/" + (matches[1]) + (matches[2] ? ("?" + (matches[2])) : '')), - width: width, - height: height - }, iframeAttrs, attrs))); - }, - function () { return this$1.setError(item); } - ); - - } - - } - - } - - ], - - methods: { - - loadItem: function(index) { - if ( index === void 0 ) index = this.index; - - - var item = this.getItem(index); - - if (!this.getSlide(item).childElementCount) { - trigger(this.$el, 'itemload', [item]); - } - }, - - getItem: function(index) { - if ( index === void 0 ) index = this.index; - - return this.items[getIndex(index, this.slides)]; - }, - - setItem: function(item, content) { - trigger(this.$el, 'itemloaded', [this, html(this.getSlide(item), content) ]); - }, - - getSlide: function(item) { - return this.slides[this.items.indexOf(item)]; - }, - - setError: function(item) { - this.setItem(item, ''); - }, - - showControls: function() { - - clearTimeout(this.controlsTimer); - this.controlsTimer = setTimeout(this.hideControls, this.delayControls); - - addClass(this.$el, 'uk-active', 'uk-transition-active'); - - }, - - hideControls: function() { - removeClass(this.$el, 'uk-active', 'uk-transition-active'); - } - - } - - }; - - function createEl(tag, attrs) { - var el = fragment(("<" + tag + ">")); - attr(el, attrs); - return el; - } - - var lightbox = { - - install: install$1, - - props: {toggle: String}, - - data: {toggle: 'a'}, - - computed: { - - toggles: { - - get: function(ref, $el) { - var toggle = ref.toggle; - - return $$(toggle, $el); - }, - - watch: function() { - this.hide(); - } - - } - - }, - - disconnected: function() { - this.hide(); - }, - - events: [ - - { - - name: 'click', - - delegate: function() { - return ((this.toggle) + ":not(.uk-disabled)"); - }, - - handler: function(e) { - e.preventDefault(); - this.show(e.current); - } - - } - - ], - - methods: { - - show: function(index) { - var this$1 = this; - - - var items = uniqueBy(this.toggles.map(toItem), 'source'); - - if (isElement(index)) { - var ref = toItem(index); - var source = ref.source; - index = findIndex(items, function (ref) { - var src = ref.source; - - return source === src; - }); - } - - this.panel = this.panel || this.$create('lightboxPanel', assign({}, this.$props, {items: items})); - - on(this.panel.$el, 'hidden', function () { return this$1.panel = false; }); - - return this.panel.show(index); - - }, - - hide: function() { - - return this.panel && this.panel.hide(); - - } - - } - - }; - - function install$1(UIkit, Lightbox) { - - if (!UIkit.lightboxPanel) { - UIkit.component('lightboxPanel', LightboxPanel); - } - - assign( - Lightbox.props, - UIkit.component('lightboxPanel').options.props - ); - - } - - function toItem(el) { - - var item = {}; - - ['href', 'caption', 'type', 'poster', 'alt', 'attrs'].forEach(function (attr) { - item[attr === 'href' ? 'source' : attr] = data(el, attr); - }); - - item.attrs = parseOptions(item.attrs); - - return item; - } - - var obj$1; - - var notification = { - - mixins: [Container], - - functional: true, - - args: ['message', 'status'], - - data: { - message: '', - status: '', - timeout: 5000, - group: null, - pos: 'top-center', - clsContainer: 'uk-notification', - clsClose: 'uk-notification-close', - clsMsg: 'uk-notification-message' - }, - - install: install, - - computed: { - - marginProp: function(ref) { - var pos = ref.pos; - - return ("margin" + (startsWith(pos, 'top') ? 'Top' : 'Bottom')); - }, - - startProps: function() { - var obj; - - return ( obj = {opacity: 0}, obj[this.marginProp] = -this.$el.offsetHeight, obj ); - } - - }, - - created: function() { - - var container = $(("." + (this.clsContainer) + "-" + (this.pos)), this.container) - || append(this.container, ("
      ")); - - this.$mount(append(container, - ("
      " + (this.message) + "
      ") - )); - - }, - - connected: function() { - var this$1 = this; - var obj; - - - var margin = toFloat(css(this.$el, this.marginProp)); - Transition.start( - css(this.$el, this.startProps), - ( obj = {opacity: 1}, obj[this.marginProp] = margin, obj ) - ).then(function () { - if (this$1.timeout) { - this$1.timer = setTimeout(this$1.close, this$1.timeout); - } - }); - - }, - - events: ( obj$1 = { - - click: function(e) { - if (closest(e.target, 'a[href="#"],a[href=""]')) { - e.preventDefault(); - } - this.close(); - } - - }, obj$1[pointerEnter] = function () { - if (this.timer) { - clearTimeout(this.timer); - } - }, obj$1[pointerLeave] = function () { - if (this.timeout) { - this.timer = setTimeout(this.close, this.timeout); - } - }, obj$1 ), - - methods: { - - close: function(immediate) { - var this$1 = this; - - - var removeFn = function (el) { - - var container = parent(el); - - trigger(el, 'close', [this$1]); - remove$1(el); - - if (container && !container.hasChildNodes()) { - remove$1(container); - } - - }; - - if (this.timer) { - clearTimeout(this.timer); - } - - if (immediate) { - removeFn(this.$el); - } else { - Transition.start(this.$el, this.startProps).then(removeFn); - } - } - - } - - }; - - function install(UIkit) { - UIkit.notification.closeAll = function (group, immediate) { - apply$1(document.body, function (el) { - var notification = UIkit.getComponent(el, 'notification'); - if (notification && (!group || group === notification.group)) { - notification.close(immediate); - } - }); - }; - } - - var props = ['x', 'y', 'bgx', 'bgy', 'rotate', 'scale', 'color', 'backgroundColor', 'borderColor', 'opacity', 'blur', 'hue', 'grayscale', 'invert', 'saturate', 'sepia', 'fopacity', 'stroke']; - - var Parallax = { - - mixins: [Media], - - props: props.reduce(function (props, prop) { - props[prop] = 'list'; - return props; - }, {}), - - data: props.reduce(function (data, prop) { - data[prop] = undefined; - return data; - }, {}), - - computed: { - - props: function(properties, $el) { - var this$1 = this; - - - return props.reduce(function (props, prop) { - - if (isUndefined(properties[prop])) { - return props; - } - - var isColor = prop.match(/color/i); - var isCssProp = isColor || prop === 'opacity'; - - var pos, bgPos, diff; - var steps = properties[prop].slice(); - - if (isCssProp) { - css($el, prop, ''); - } - - if (steps.length < 2) { - steps.unshift((prop === 'scale' - ? 1 - : isCssProp - ? css($el, prop) - : 0) || 0); - } - - var unit = getUnit(steps); - - if (isColor) { - - var ref = $el.style; - var color = ref.color; - steps = steps.map(function (step) { return parseColor($el, step); }); - $el.style.color = color; - - } else if (startsWith(prop, 'bg')) { - - var attr = prop === 'bgy' ? 'height' : 'width'; - steps = steps.map(function (step) { return toPx(step, attr, this$1.$el); }); - - css($el, ("background-position-" + (prop[2])), ''); - bgPos = css($el, 'backgroundPosition').split(' ')[prop[2] === 'x' ? 0 : 1]; // IE 11 can't read background-position-[x|y] - - if (this$1.covers) { - - var min = Math.min.apply(Math, steps); - var max = Math.max.apply(Math, steps); - var down = steps.indexOf(min) < steps.indexOf(max); - - diff = max - min; - - steps = steps.map(function (step) { return step - (down ? min : max); }); - pos = (down ? -diff : 0) + "px"; - - } else { - - pos = bgPos; - - } - - } else { - - steps = steps.map(toFloat); - - } - - if (prop === 'stroke') { - - if (!steps.some(function (step) { return step; })) { - return props; - } - - var length = getMaxPathLength(this$1.$el); - css($el, 'strokeDasharray', length); - - if (unit === '%') { - steps = steps.map(function (step) { return step * length / 100; }); - } - - steps = steps.reverse(); - - prop = 'strokeDashoffset'; - } - - props[prop] = {steps: steps, unit: unit, pos: pos, bgPos: bgPos, diff: diff}; - - return props; - - }, {}); - - }, - - bgProps: function() { - var this$1 = this; - - return ['bgx', 'bgy'].filter(function (bg) { return bg in this$1.props; }); - }, - - covers: function(_, $el) { - return covers($el); - } - - }, - - disconnected: function() { - delete this._image; - }, - - update: { - - read: function(data) { - var this$1 = this; - - - if (!this.matchMedia) { - return; - } - - if (!data.image && this.covers && this.bgProps.length) { - var src = css(this.$el, 'backgroundImage').replace(/^none|url\(["']?(.+?)["']?\)$/, '$1'); - - if (src) { - var img = new Image(); - img.src = src; - data.image = img; - - if (!img.naturalWidth) { - img.onload = function () { return this$1.$update(); }; - } - } - - } - - var image = data.image; - - if (!image || !image.naturalWidth) { - return; - } - - var dimEl = { - width: this.$el.offsetWidth, - height: this.$el.offsetHeight - }; - var dimImage = { - width: image.naturalWidth, - height: image.naturalHeight - }; - - var dim = Dimensions.cover(dimImage, dimEl); - - this.bgProps.forEach(function (prop) { - - var ref = this$1.props[prop]; - var diff = ref.diff; - var bgPos = ref.bgPos; - var steps = ref.steps; - var attr = prop === 'bgy' ? 'height' : 'width'; - var span = dim[attr] - dimEl[attr]; - - if (span < diff) { - dimEl[attr] = dim[attr] + diff - span; - } else if (span > diff) { - - var posPercentage = dimEl[attr] / toPx(bgPos, attr, this$1.$el); - - if (posPercentage) { - this$1.props[prop].steps = steps.map(function (step) { return step - (span - diff) / posPercentage; }); - } - } - - dim = Dimensions.cover(dimImage, dimEl); - }); - - data.dim = dim; - }, - - write: function(ref) { - var dim = ref.dim; - - - if (!this.matchMedia) { - css(this.$el, {backgroundSize: '', backgroundRepeat: ''}); - return; - } - - dim && css(this.$el, { - backgroundSize: ((dim.width) + "px " + (dim.height) + "px"), - backgroundRepeat: 'no-repeat' - }); - - }, - - events: ['resize'] - - }, - - methods: { - - reset: function() { - var this$1 = this; - - each(this.getCss(0), function (_, prop) { return css(this$1.$el, prop, ''); }); - }, - - getCss: function(percent) { - - var ref = this; - var props = ref.props; - return Object.keys(props).reduce(function (css, prop) { - - var ref = props[prop]; - var steps = ref.steps; - var unit = ref.unit; - var pos = ref.pos; - var value = getValue(steps, percent); - - switch (prop) { - - // transforms - case 'x': - case 'y': { - unit = unit || 'px'; - css.transform += " translate" + (ucfirst(prop)) + "(" + (toFloat(value).toFixed(unit === 'px' ? 0 : 2)) + unit + ")"; - break; - } - case 'rotate': - unit = unit || 'deg'; - css.transform += " rotate(" + (value + unit) + ")"; - break; - case 'scale': - css.transform += " scale(" + value + ")"; - break; - - // bg image - case 'bgy': - case 'bgx': - css[("background-position-" + (prop[2]))] = "calc(" + pos + " + " + value + "px)"; - break; - - // color - case 'color': - case 'backgroundColor': - case 'borderColor': { - - var ref$1 = getStep(steps, percent); - var start = ref$1[0]; - var end = ref$1[1]; - var p = ref$1[2]; - - css[prop] = "rgba(" + (start.map(function (value, i) { - value = value + p * (end[i] - value); - return i === 3 ? toFloat(value) : parseInt(value, 10); - }).join(',')) + ")"; - break; - } - // CSS Filter - case 'blur': - unit = unit || 'px'; - css.filter += " blur(" + (value + unit) + ")"; - break; - case 'hue': - unit = unit || 'deg'; - css.filter += " hue-rotate(" + (value + unit) + ")"; - break; - case 'fopacity': - unit = unit || '%'; - css.filter += " opacity(" + (value + unit) + ")"; - break; - case 'grayscale': - case 'invert': - case 'saturate': - case 'sepia': - unit = unit || '%'; - css.filter += " " + prop + "(" + (value + unit) + ")"; - break; - default: - css[prop] = value; - } - - return css; - - }, {transform: '', filter: ''}); - - } - - } - - }; - - function parseColor(el, color) { - return css(css(el, 'color', color), 'color') - .split(/[(),]/g) - .slice(1, -1) - .concat(1) - .slice(0, 4) - .map(toFloat); - } - - function getStep(steps, percent) { - var count = steps.length - 1; - var index = Math.min(Math.floor(count * percent), count - 1); - var step = steps.slice(index, index + 2); - - step.push(percent === 1 ? 1 : percent % (1 / count) * count); - - return step; - } - - function getValue(steps, percent, digits) { - if ( digits === void 0 ) digits = 2; - - var ref = getStep(steps, percent); - var start = ref[0]; - var end = ref[1]; - var p = ref[2]; - return (isNumber(start) - ? start + Math.abs(start - end) * p * (start < end ? 1 : -1) - : +end - ).toFixed(digits); - } - - function getUnit(steps) { - return steps.reduce(function (unit, step) { return isString(step) && step.replace(/-|\d/g, '').trim() || unit; }, ''); - } - - function covers(el) { - var ref = el.style; - var backgroundSize = ref.backgroundSize; - var covers = css(css(el, 'backgroundSize', ''), 'backgroundSize') === 'cover'; - el.style.backgroundSize = backgroundSize; - return covers; - } - - var parallax = { - - mixins: [Parallax], - - props: { - target: String, - viewport: Number, - easing: Number - }, - - data: { - target: false, - viewport: 1, - easing: 1 - }, - - computed: { - - target: function(ref, $el) { - var target = ref.target; - - return getOffsetElement(target && query(target, $el) || $el); - } - - }, - - update: { - - read: function(ref, types) { - var percent = ref.percent; - - - if (!types.has('scroll')) { - percent = false; - } - - if (!this.matchMedia) { - return; - } - - var prev = percent; - percent = ease(scrolledOver(this.target) / (this.viewport || 1), this.easing); - - return { - percent: percent, - style: prev !== percent ? this.getCss(percent) : false - }; - }, - - write: function(ref) { - var style = ref.style; - - - if (!this.matchMedia) { - this.reset(); - return; - } - - style && css(this.$el, style); - - }, - - events: ['scroll', 'resize'] - } - - }; - - function ease(percent, easing) { - return clamp(percent * (1 - (easing - easing * percent))); - } - - // SVG elements do not inherit from HTMLElement - function getOffsetElement(el) { - return el - ? 'offsetTop' in el - ? el - : getOffsetElement(parent(el)) - : document.body; - } - - var SliderReactive = { - - update: { - - write: function() { - - if (this.stack.length || this.dragging) { - return; - } - - var index = this.getValidIndex(this.index); - - if (!~this.prevIndex || this.index !== index) { - this.show(index); - } - - }, - - events: ['resize'] - - } - - }; - - function Transitioner (prev, next, dir, ref) { - var center = ref.center; - var easing = ref.easing; - var list = ref.list; - - - var deferred = new Deferred(); - - var from = prev - ? getLeft(prev, list, center) - : getLeft(next, list, center) + dimensions(next).width * dir; - var to = next - ? getLeft(next, list, center) - : from + dimensions(prev).width * dir * (isRtl ? -1 : 1); - - return { - - dir: dir, - - show: function(duration, percent, linear) { - if ( percent === void 0 ) percent = 0; - - - var timing = linear ? 'linear' : easing; - duration -= Math.round(duration * clamp(percent, -1, 1)); - - this.translate(percent); - - percent = prev ? percent : clamp(percent, 0, 1); - triggerUpdate(this.getItemIn(), 'itemin', {percent: percent, duration: duration, timing: timing, dir: dir}); - prev && triggerUpdate(this.getItemIn(true), 'itemout', {percent: 1 - percent, duration: duration, timing: timing, dir: dir}); - - Transition - .start(list, {transform: translate(-to * (isRtl ? -1 : 1), 'px')}, duration, timing) - .then(deferred.resolve, noop); - - return deferred.promise; - - }, - - cancel: function() { - Transition.cancel(list); - }, - - reset: function() { - css(list, 'transform', ''); - }, - - forward: function(duration, percent) { - if ( percent === void 0 ) percent = this.percent(); - - Transition.cancel(list); - return this.show(duration, percent, true); - }, - - translate: function(percent) { - - var distance = this.getDistance() * dir * (isRtl ? -1 : 1); - - css(list, 'transform', translate(clamp( - -to + (distance - distance * percent), - -getWidth(list), - dimensions(list).width - ) * (isRtl ? -1 : 1), 'px')); - - var actives = this.getActives(); - var itemIn = this.getItemIn(); - var itemOut = this.getItemIn(true); - - percent = prev ? clamp(percent, -1, 1) : 0; - - children(list).forEach(function (slide) { - var isActive = includes(actives, slide); - var isIn = slide === itemIn; - var isOut = slide === itemOut; - var translateIn = isIn || !isOut && (isActive || dir * (isRtl ? -1 : 1) === -1 ^ getElLeft(slide, list) > getElLeft(prev || next)); - - triggerUpdate(slide, ("itemtranslate" + (translateIn ? 'in' : 'out')), { - dir: dir, - percent: isOut - ? 1 - percent - : isIn - ? percent - : isActive - ? 1 - : 0 - }); - }); - - }, - - percent: function() { - return Math.abs((css(list, 'transform').split(',')[4] * (isRtl ? -1 : 1) + from) / (to - from)); - }, - - getDistance: function() { - return Math.abs(to - from); - }, - - getItemIn: function(out) { - if ( out === void 0 ) out = false; - - - var actives = this.getActives(); - var nextActives = inView(list, getLeft(next || prev, list, center)); - - if (out) { - var temp = actives; - actives = nextActives; - nextActives = temp; - } - - return nextActives[findIndex(nextActives, function (el) { return !includes(actives, el); })]; - - }, - - getActives: function() { - return inView(list, getLeft(prev || next, list, center)); - } - - }; - - } - - function getLeft(el, list, center) { - - var left = getElLeft(el, list); - - return center - ? left - centerEl(el, list) - : Math.min(left, getMax(list)); - - } - - function getMax(list) { - return Math.max(0, getWidth(list) - dimensions(list).width); - } - - function getWidth(list) { - return children(list).reduce(function (right, el) { return dimensions(el).width + right; }, 0); - } - - function centerEl(el, list) { - return dimensions(list).width / 2 - dimensions(el).width / 2; - } - - function getElLeft(el, list) { - return el && (position(el).left + (isRtl ? dimensions(el).width - dimensions(list).width : 0)) * (isRtl ? -1 : 1) || 0; - } - - function inView(list, listLeft) { - - listLeft -= 1; - var listRight = listLeft + dimensions(list).width + 2; - - return children(list).filter(function (slide) { - var slideLeft = getElLeft(slide, list); - var slideRight = slideLeft + dimensions(slide).width; - - return slideLeft >= listLeft && slideRight <= listRight; - }); - } - - function triggerUpdate(el, type, data) { - trigger(el, createEvent(type, false, false, data)); - } - - var slider = { - - mixins: [Class, Slider, SliderReactive], - - props: { - center: Boolean, - sets: Boolean - }, - - data: { - center: false, - sets: false, - attrItem: 'uk-slider-item', - selList: '.uk-slider-items', - selNav: '.uk-slider-nav', - clsContainer: 'uk-slider-container', - Transitioner: Transitioner - }, - - computed: { - - avgWidth: function() { - return getWidth(this.list) / this.length; - }, - - finite: function(ref) { - var finite = ref.finite; - - return finite || Math.ceil(getWidth(this.list)) < dimensions(this.list).width + getMaxElWidth(this.list) + this.center; - }, - - maxIndex: function() { - - if (!this.finite || this.center && !this.sets) { - return this.length - 1; - } - - if (this.center) { - return last(this.sets); - } - - var lft = 0; - var max = getMax(this.list); - var index = findIndex(this.slides, function (el) { - - if (lft >= max) { - return true; - } - - lft += dimensions(el).width; - - }); - - return ~index ? index : this.length - 1; - }, - - sets: function(ref) { - var this$1 = this; - var sets = ref.sets; - - - if (!sets) { - return; - } - - var width = dimensions(this.list).width / (this.center ? 2 : 1); - - var left = 0; - var leftCenter = width; - var slideLeft = 0; - - sets = sortBy$1(this.slides, 'offsetLeft').reduce(function (sets, slide, i) { - - var slideWidth = dimensions(slide).width; - var slideRight = slideLeft + slideWidth; - - if (slideRight > left) { - - if (!this$1.center && i > this$1.maxIndex) { - i = this$1.maxIndex; - } - - if (!includes(sets, i)) { - - var cmp = this$1.slides[i + 1]; - if (this$1.center && cmp && slideWidth < leftCenter - dimensions(cmp).width / 2) { - leftCenter -= slideWidth; - } else { - leftCenter = width; - sets.push(i); - left = slideLeft + width + (this$1.center ? slideWidth / 2 : 0); - } - - } - } - - slideLeft += slideWidth; - - return sets; - - }, []); - - return !isEmpty(sets) && sets; - - }, - - transitionOptions: function() { - return { - center: this.center, - list: this.list - }; - } - - }, - - connected: function() { - toggleClass(this.$el, this.clsContainer, !$(("." + (this.clsContainer)), this.$el)); - }, - - update: { - - write: function() { - var this$1 = this; - - this.navItems.forEach(function (el) { - var index = toNumber(data(el, this$1.attrItem)); - if (index !== false) { - el.hidden = !this$1.maxIndex || index > this$1.maxIndex || this$1.sets && !includes(this$1.sets, index); - } - }); - - if (this.length && !this.dragging && !this.stack.length) { - this.reorder(); - this._translate(1); - } - - var actives = this._getTransitioner(this.index).getActives(); - this.slides.forEach(function (slide) { return toggleClass(slide, this$1.clsActive, includes(actives, slide)); }); - - if (this.clsActivated && (!this.sets || includes(this.sets, toFloat(this.index)))) { - this.slides.forEach(function (slide) { return toggleClass(slide, this$1.clsActivated || '', includes(actives, slide)); }); - } - }, - - events: ['resize'] - - }, - - events: { - - beforeitemshow: function(e) { - - if (!this.dragging && this.sets && this.stack.length < 2 && !includes(this.sets, this.index)) { - this.index = this.getValidIndex(); - } - - var diff = Math.abs( - this.index - - this.prevIndex - + (this.dir > 0 && this.index < this.prevIndex || this.dir < 0 && this.index > this.prevIndex ? (this.maxIndex + 1) * this.dir : 0) - ); - - if (!this.dragging && diff > 1) { - - for (var i = 0; i < diff; i++) { - this.stack.splice(1, 0, this.dir > 0 ? 'next' : 'previous'); - } - - e.preventDefault(); - return; - } - - var index = this.dir < 0 || !this.slides[this.prevIndex] ? this.index : this.prevIndex; - this.duration = speedUp(this.avgWidth / this.velocity) * (dimensions(this.slides[index]).width / this.avgWidth); - - this.reorder(); - - }, - - itemshow: function() { - if (~this.prevIndex) { - addClass(this._getTransitioner().getItemIn(), this.clsActive); - } - } - - }, - - methods: { - - reorder: function() { - var this$1 = this; - - - if (this.finite) { - css(this.slides, 'order', ''); - return; - } - - var index = this.dir > 0 && this.slides[this.prevIndex] ? this.prevIndex : this.index; - - this.slides.forEach(function (slide, i) { return css(slide, 'order', this$1.dir > 0 && i < index - ? 1 - : this$1.dir < 0 && i >= this$1.index - ? -1 - : '' - ); } - ); - - if (!this.center) { - return; - } - - var next = this.slides[index]; - var width = dimensions(this.list).width / 2 - dimensions(next).width / 2; - var j = 0; - - while (width > 0) { - var slideIndex = this.getIndex(--j + index, index); - var slide = this.slides[slideIndex]; - - css(slide, 'order', slideIndex > index ? -2 : -1); - width -= dimensions(slide).width; - } - - }, - - getValidIndex: function(index, prevIndex) { - if ( index === void 0 ) index = this.index; - if ( prevIndex === void 0 ) prevIndex = this.prevIndex; - - - index = this.getIndex(index, prevIndex); - - if (!this.sets) { - return index; - } - - var prev; - - do { - - if (includes(this.sets, index)) { - return index; - } - - prev = index; - index = this.getIndex(index + this.dir, prevIndex); - - } while (index !== prev); - - return index; - } - - } - - }; - - function getMaxElWidth(list) { - return Math.max.apply(Math, [ 0 ].concat( children(list).map(function (el) { return dimensions(el).width; }) )); - } - - var sliderParallax = { - - mixins: [Parallax], - - data: { - selItem: '!li' - }, - - computed: { - - item: function(ref, $el) { - var selItem = ref.selItem; - - return query(selItem, $el); - } - - }, - - events: [ - - { - name: 'itemin itemout', - - self: true, - - el: function() { - return this.item; - }, - - handler: function(ref) { - var this$1 = this; - var type = ref.type; - var ref_detail = ref.detail; - var percent = ref_detail.percent; - var duration = ref_detail.duration; - var timing = ref_detail.timing; - var dir = ref_detail.dir; - - - fastdom.read(function () { - var propsFrom = this$1.getCss(getCurrentPercent(type, dir, percent)); - var propsTo = this$1.getCss(isIn(type) ? .5 : dir > 0 ? 1 : 0); - fastdom.write(function () { - css(this$1.$el, propsFrom); - Transition.start(this$1.$el, propsTo, duration, timing).catch(noop); - }); - }); - - } - }, - - { - name: 'transitioncanceled transitionend', - - self: true, - - el: function() { - return this.item; - }, - - handler: function() { - Transition.cancel(this.$el); - } - - }, - - { - name: 'itemtranslatein itemtranslateout', - - self: true, - - el: function() { - return this.item; - }, - - handler: function(ref) { - var this$1 = this; - var type = ref.type; - var ref_detail = ref.detail; - var percent = ref_detail.percent; - var dir = ref_detail.dir; - - fastdom.read(function () { - var props = this$1.getCss(getCurrentPercent(type, dir, percent)); - fastdom.write(function () { return css(this$1.$el, props); }); - }); - } - } - - ] - - }; - - function isIn(type) { - return endsWith(type, 'in'); - } - - function getCurrentPercent(type, dir, percent) { - - percent /= 2; - - return !isIn(type) - ? dir < 0 - ? percent - : 1 - percent - : dir < 0 - ? 1 - percent - : percent; - } - - var Animations = assign({}, Animations$2, { - - fade: { - - show: function() { - return [ - {opacity: 0, zIndex: 0}, - {zIndex: -1} - ]; - }, - - percent: function(current) { - return 1 - css(current, 'opacity'); - }, - - translate: function(percent) { - return [ - {opacity: 1 - percent, zIndex: 0}, - {zIndex: -1} - ]; - } - - }, - - scale: { - - show: function() { - return [ - {opacity: 0, transform: scale3d(1 + .5), zIndex: 0}, - {zIndex: -1} - ]; - }, - - percent: function(current) { - return 1 - css(current, 'opacity'); - }, - - translate: function(percent) { - return [ - {opacity: 1 - percent, transform: scale3d(1 + .5 * percent), zIndex: 0}, - {zIndex: -1} - ]; - } - - }, - - pull: { - - show: function(dir) { - return dir < 0 - ? [ - {transform: translate(30), zIndex: -1}, - {transform: translate(), zIndex: 0} - ] - : [ - {transform: translate(-100), zIndex: 0}, - {transform: translate(), zIndex: -1} - ]; - }, - - percent: function(current, next, dir) { - return dir < 0 - ? 1 - translated(next) - : translated(current); - }, - - translate: function(percent, dir) { - return dir < 0 - ? [ - {transform: translate(30 * percent), zIndex: -1}, - {transform: translate(-100 * (1 - percent)), zIndex: 0} - ] - : [ - {transform: translate(-percent * 100), zIndex: 0}, - {transform: translate(30 * (1 - percent)), zIndex: -1} - ]; - } - - }, - - push: { - - show: function(dir) { - return dir < 0 - ? [ - {transform: translate(100), zIndex: 0}, - {transform: translate(), zIndex: -1} - ] - : [ - {transform: translate(-30), zIndex: -1}, - {transform: translate(), zIndex: 0} - ]; - }, - - percent: function(current, next, dir) { - return dir > 0 - ? 1 - translated(next) - : translated(current); - }, - - translate: function(percent, dir) { - return dir < 0 - ? [ - {transform: translate(percent * 100), zIndex: 0}, - {transform: translate(-30 * (1 - percent)), zIndex: -1} - ] - : [ - {transform: translate(-30 * percent), zIndex: -1}, - {transform: translate(100 * (1 - percent)), zIndex: 0} - ]; - } - - } - - }); - - var slideshow = { - - mixins: [Class, Slideshow, SliderReactive], - - props: { - ratio: String, - minHeight: Number, - maxHeight: Number - }, - - data: { - ratio: '16:9', - minHeight: false, - maxHeight: false, - selList: '.uk-slideshow-items', - attrItem: 'uk-slideshow-item', - selNav: '.uk-slideshow-nav', - Animations: Animations - }, - - update: { - - read: function() { - - var ref = this.ratio.split(':').map(Number); - var width = ref[0]; - var height = ref[1]; - - height = height * this.list.offsetWidth / width || 0; - - if (this.minHeight) { - height = Math.max(this.minHeight, height); - } - - if (this.maxHeight) { - height = Math.min(this.maxHeight, height); - } - - return {height: height - boxModelAdjust(this.list, 'height', 'content-box')}; - }, - - write: function(ref) { - var height = ref.height; - - height > 0 && css(this.list, 'minHeight', height); - }, - - events: ['resize'] - - } - - }; - - var sortable = { - - mixins: [Class, Animate], - - props: { - group: String, - threshold: Number, - clsItem: String, - clsPlaceholder: String, - clsDrag: String, - clsDragState: String, - clsBase: String, - clsNoDrag: String, - clsEmpty: String, - clsCustom: String, - handle: String - }, - - data: { - group: false, - threshold: 5, - clsItem: 'uk-sortable-item', - clsPlaceholder: 'uk-sortable-placeholder', - clsDrag: 'uk-sortable-drag', - clsDragState: 'uk-drag', - clsBase: 'uk-sortable', - clsNoDrag: 'uk-sortable-nodrag', - clsEmpty: 'uk-sortable-empty', - clsCustom: '', - handle: false, - pos: {} - }, - - created: function() { - var this$1 = this; - - ['init', 'start', 'move', 'end'].forEach(function (key) { - var fn = this$1[key]; - this$1[key] = function (e) { - assign(this$1.pos, getEventPos(e)); - fn(e); - }; - }); - }, - - events: { - - name: pointerDown, - passive: false, - handler: 'init' - - }, - - computed: { - - target: function() { - return (this.$el.tBodies || [this.$el])[0]; - }, - - items: function() { - return children(this.target); - }, - - isEmpty: { - - get: function() { - return isEmpty(this.items); - }, - - watch: function(empty) { - toggleClass(this.target, this.clsEmpty, empty); - }, - - immediate: true - - }, - - handles: { - - get: function(ref, el) { - var handle = ref.handle; - - return handle ? $$(handle, el) : this.items; - }, - - watch: function(handles, prev) { - css(prev, {touchAction: '', userSelect: ''}); - css(handles, {touchAction: hasTouch ? 'none' : '', userSelect: 'none'}); // touchAction set to 'none' causes a performance drop in Chrome 80 - }, - - immediate: true - - } - - }, - - update: { - - write: function(data) { - - if (!this.drag || !parent(this.placeholder)) { - return; - } - - var ref = this; - var ref_pos = ref.pos; - var x = ref_pos.x; - var y = ref_pos.y; - var ref_origin = ref.origin; - var offsetTop = ref_origin.offsetTop; - var offsetLeft = ref_origin.offsetLeft; - var placeholder = ref.placeholder; - - css(this.drag, { - top: y - offsetTop, - left: x - offsetLeft - }); - - var sortable = this.getSortable(document.elementFromPoint(x, y)); - - if (!sortable) { - return; - } - - var items = sortable.items; - - if (items.some(Transition.inProgress)) { - return; - } - - var target = findTarget(items, {x: x, y: y}); - - if (items.length && (!target || target === placeholder)) { - return; - } - - var previous = this.getSortable(placeholder); - var insertTarget = findInsertTarget(sortable.target, target, placeholder, x, y, sortable === previous && data.moved !== target); - - if (insertTarget === false) { - return; - } - - if (insertTarget && placeholder === insertTarget) { - return; - } - - if (sortable !== previous) { - previous.remove(placeholder); - data.moved = target; - } else { - delete data.moved; - } - - sortable.insert(placeholder, insertTarget); - - this.touched.add(sortable); - }, - - events: ['move'] - - }, - - methods: { - - init: function(e) { - - var target = e.target; - var button = e.button; - var defaultPrevented = e.defaultPrevented; - var ref = this.items.filter(function (el) { return within(target, el); }); - var placeholder = ref[0]; - - if (!placeholder - || defaultPrevented - || button > 0 - || isInput(target) - || within(target, ("." + (this.clsNoDrag))) - || this.handle && !within(target, this.handle) - ) { - return; - } - - e.preventDefault(); - - this.touched = new Set([this]); - this.placeholder = placeholder; - this.origin = assign({target: target, index: index(placeholder)}, this.pos); - - on(document, pointerMove, this.move); - on(document, pointerUp, this.end); - - if (!this.threshold) { - this.start(e); - } - - }, - - start: function(e) { - - this.drag = appendDrag(this.$container, this.placeholder); - var ref = this.placeholder.getBoundingClientRect(); - var left = ref.left; - var top = ref.top; - assign(this.origin, {offsetLeft: this.pos.x - left, offsetTop: this.pos.y - top}); - - addClass(this.drag, this.clsDrag, this.clsCustom); - addClass(this.placeholder, this.clsPlaceholder); - addClass(this.items, this.clsItem); - addClass(document.documentElement, this.clsDragState); - - trigger(this.$el, 'start', [this, this.placeholder]); - - trackScroll(this.pos); - - this.move(e); - }, - - move: function(e) { - - if (this.drag) { - this.$emit('move'); - } else if (Math.abs(this.pos.x - this.origin.x) > this.threshold || Math.abs(this.pos.y - this.origin.y) > this.threshold) { - this.start(e); - } - - }, - - end: function() { - var this$1 = this; - - - off(document, pointerMove, this.move); - off(document, pointerUp, this.end); - off(window, 'scroll', this.scroll); - - if (!this.drag) { - return; - } - - untrackScroll(); - - var sortable = this.getSortable(this.placeholder); - - if (this === sortable) { - if (this.origin.index !== index(this.placeholder)) { - trigger(this.$el, 'moved', [this, this.placeholder]); - } - } else { - trigger(sortable.$el, 'added', [sortable, this.placeholder]); - trigger(this.$el, 'removed', [this, this.placeholder]); - } - - trigger(this.$el, 'stop', [this, this.placeholder]); - - remove$1(this.drag); - this.drag = null; - - this.touched.forEach(function (ref) { - var clsPlaceholder = ref.clsPlaceholder; - var clsItem = ref.clsItem; - - return this$1.touched.forEach(function (sortable) { return removeClass(sortable.items, clsPlaceholder, clsItem); } - ); - } - ); - this.touched = null; - removeClass(document.documentElement, this.clsDragState); - - }, - - insert: function(element, target) { - var this$1 = this; - - - addClass(this.items, this.clsItem); - - var insert = function () { return target - ? before(target, element) - : append(this$1.target, element); }; - - this.animate(insert); - - }, - - remove: function(element) { - - if (!within(element, this.target)) { - return; - } - - this.animate(function () { return remove$1(element); }); - - }, - - getSortable: function(element) { - do { - var sortable = this.$getComponent(element, 'sortable'); - - if (sortable && (sortable === this || this.group !== false && sortable.group === this.group)) { - return sortable; - } - } while ((element = parent(element))); - } - - } - - }; - - var trackTimer; - function trackScroll(pos) { - - var last = Date.now(); - trackTimer = setInterval(function () { - - var x = pos.x; - var y = pos.y; - y += window.pageYOffset; - - var dist = (Date.now() - last) * .3; - last = Date.now(); - - scrollParents(document.elementFromPoint(x, pos.y)).reverse().some(function (scrollEl) { - - var scroll = scrollEl.scrollTop; - var scrollHeight = scrollEl.scrollHeight; - - var ref = offset(getViewport$1(scrollEl)); - var top = ref.top; - var bottom = ref.bottom; - var height = ref.height; - - if (top < y && top + 35 > y) { - scroll -= dist; - } else if (bottom > y && bottom - 35 < y) { - scroll += dist; - } else { - return; - } - - if (scroll > 0 && scroll < scrollHeight - height) { - scrollTop(scrollEl, scroll); - return true; - } - - }); - - }, 15); - - } - - function untrackScroll() { - clearInterval(trackTimer); - } - - function appendDrag(container, element) { - var clone = append(container, element.outerHTML.replace(/(^<)(?:li|tr)|(?:li|tr)(\/>$)/g, '$1div$2')); - - css(clone, 'margin', '0', 'important'); - css(clone, assign({ - boxSizing: 'border-box', - width: element.offsetWidth, - height: element.offsetHeight - }, css(element, ['paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom']))); - - height(clone.firstElementChild, height(element.firstElementChild)); - - return clone; - } - - function findTarget(items, point) { - return items[findIndex(items, function (item) { return pointInRect(point, item.getBoundingClientRect()); })]; - } - - function findInsertTarget(list, target, placeholder, x, y, sameList) { - - if (!children(list).length) { - return; - } - - var rect = target.getBoundingClientRect(); - if (!sameList) { - - if (!isHorizontal(list, placeholder)) { - return y < rect.top + rect.height / 2 - ? target - : target.nextElementSibling; - } - - return target; - } - - var placeholderRect = placeholder.getBoundingClientRect(); - var sameRow = linesIntersect( - [rect.top, rect.bottom], - [placeholderRect.top, placeholderRect.bottom] - ); - - var pointerPos = sameRow ? x : y; - var lengthProp = sameRow ? 'width' : 'height'; - var startProp = sameRow ? 'left' : 'top'; - var endProp = sameRow ? 'right' : 'bottom'; - - var diff = placeholderRect[lengthProp] < rect[lengthProp] ? rect[lengthProp] - placeholderRect[lengthProp] : 0; - - if (placeholderRect[startProp] < rect[startProp]) { - - if (diff && pointerPos < rect[startProp] + diff) { - return false; - } - - return target.nextElementSibling; - } - - if (diff && pointerPos > rect[endProp] - diff) { - return false; - } - - return target; - } - - function isHorizontal(list, placeholder) { - - var single = children(list).length === 1; - - if (single) { - append(list, placeholder); - } - - var items = children(list); - var isHorizontal = items.some(function (el, i) { - var rectA = el.getBoundingClientRect(); - return items.slice(i + 1).some(function (el) { - var rectB = el.getBoundingClientRect(); - return !linesIntersect([rectA.left, rectA.right], [rectB.left, rectB.right]); - }); - }); - - if (single) { - remove$1(placeholder); - } - - return isHorizontal; - } - - function linesIntersect(lineA, lineB) { - return lineA[1] > lineB[0] && lineB[1] > lineA[0]; - } - - var obj; - - var tooltip = { - - mixins: [Container, Togglable, Position], - - args: 'title', - - props: { - delay: Number, - title: String - }, - - data: { - pos: 'top', - title: '', - delay: 0, - animation: ['uk-animation-scale-up'], - duration: 100, - cls: 'uk-active', - clsPos: 'uk-tooltip' - }, - - beforeConnect: function() { - this._hasTitle = hasAttr(this.$el, 'title'); - attr(this.$el, 'title', ''); - this.updateAria(false); - makeFocusable(this.$el); - }, - - disconnected: function() { - this.hide(); - attr(this.$el, 'title', this._hasTitle ? this.title : null); - }, - - methods: { - - show: function() { - var this$1 = this; - - - if (this.isToggled(this.tooltip || null) || !this.title) { - return; - } - - this._unbind = once(document, ("show keydown " + pointerDown), this.hide, false, function (e) { return e.type === pointerDown && !within(e.target, this$1.$el) - || e.type === 'keydown' && e.keyCode === 27 - || e.type === 'show' && e.detail[0] !== this$1 && e.detail[0].$name === this$1.$name; } - ); - - clearTimeout(this.showTimer); - this.showTimer = setTimeout(this._show, this.delay); - }, - - hide: function() { - var this$1 = this; - - - if (matches(this.$el, 'input:focus')) { - return; - } - - clearTimeout(this.showTimer); - - if (!this.isToggled(this.tooltip || null)) { - return; - } - - this.toggleElement(this.tooltip, false, false).then(function () { - this$1.tooltip = remove$1(this$1.tooltip); - this$1._unbind(); - }); - }, - - _show: function() { - var this$1 = this; - - - this.tooltip = append(this.container, - ("
      " + (this.title) + "
      ") - ); - - on(this.tooltip, 'toggled', function (e, toggled) { - - this$1.updateAria(toggled); - - if (!toggled) { - return; - } - - this$1.positionAt(this$1.tooltip, this$1.$el); - - this$1.origin = this$1.getAxis() === 'y' - ? ((flipPosition(this$1.dir)) + "-" + (this$1.align)) - : ((this$1.align) + "-" + (flipPosition(this$1.dir))); - }); - - this.toggleElement(this.tooltip, true); - - }, - - updateAria: function(toggled) { - attr(this.$el, 'aria-expanded', toggled); - } - - }, - - events: ( obj = { - - focus: 'show', - blur: 'hide' - - }, obj[(pointerEnter + " " + pointerLeave)] = function (e) { - if (!isTouch(e)) { - this[e.type === pointerEnter ? 'show' : 'hide'](); - } - }, obj[pointerDown] = function (e) { - if (isTouch(e)) { - this.show(); - } - }, obj ) - - }; - - function makeFocusable(el) { - if (!isFocusable(el)) { - attr(el, 'tabindex', '0'); - } - } - - var upload = { - - props: { - allow: String, - clsDragover: String, - concurrent: Number, - maxSize: Number, - method: String, - mime: String, - msgInvalidMime: String, - msgInvalidName: String, - msgInvalidSize: String, - multiple: Boolean, - name: String, - params: Object, - type: String, - url: String - }, - - data: { - allow: false, - clsDragover: 'uk-dragover', - concurrent: 1, - maxSize: 0, - method: 'POST', - mime: false, - msgInvalidMime: 'Invalid File Type: %s', - msgInvalidName: 'Invalid File Name: %s', - msgInvalidSize: 'Invalid File Size: %s Kilobytes Max', - multiple: false, - name: 'files[]', - params: {}, - type: '', - url: '', - abort: noop, - beforeAll: noop, - beforeSend: noop, - complete: noop, - completeAll: noop, - error: noop, - fail: noop, - load: noop, - loadEnd: noop, - loadStart: noop, - progress: noop - }, - - events: { - - change: function(e) { - - if (!matches(e.target, 'input[type="file"]')) { - return; - } - - e.preventDefault(); - - if (e.target.files) { - this.upload(e.target.files); - } - - e.target.value = ''; - }, - - drop: function(e) { - stop(e); - - var transfer = e.dataTransfer; - - if (!transfer || !transfer.files) { - return; - } - - removeClass(this.$el, this.clsDragover); - - this.upload(transfer.files); - }, - - dragenter: function(e) { - stop(e); - }, - - dragover: function(e) { - stop(e); - addClass(this.$el, this.clsDragover); - }, - - dragleave: function(e) { - stop(e); - removeClass(this.$el, this.clsDragover); - } - - }, - - methods: { - - upload: function(files) { - var this$1 = this; - - - if (!files.length) { - return; - } - - trigger(this.$el, 'upload', [files]); - - for (var i = 0; i < files.length; i++) { - - if (this.maxSize && this.maxSize * 1000 < files[i].size) { - this.fail(this.msgInvalidSize.replace('%s', this.maxSize)); - return; - } - - if (this.allow && !match(this.allow, files[i].name)) { - this.fail(this.msgInvalidName.replace('%s', this.allow)); - return; - } - - if (this.mime && !match(this.mime, files[i].type)) { - this.fail(this.msgInvalidMime.replace('%s', this.mime)); - return; - } - - } - - if (!this.multiple) { - files = [files[0]]; - } - - this.beforeAll(this, files); - - var chunks = chunk(files, this.concurrent); - var upload = function (files) { - - var data = new FormData(); - - files.forEach(function (file) { return data.append(this$1.name, file); }); - - for (var key in this$1.params) { - data.append(key, this$1.params[key]); - } - - ajax(this$1.url, { - data: data, - method: this$1.method, - responseType: this$1.type, - beforeSend: function (env) { - - var xhr = env.xhr; - xhr.upload && on(xhr.upload, 'progress', this$1.progress); - ['loadStart', 'load', 'loadEnd', 'abort'].forEach(function (type) { return on(xhr, type.toLowerCase(), this$1[type]); } - ); - - return this$1.beforeSend(env); - - } - }).then( - function (xhr) { - - this$1.complete(xhr); - - if (chunks.length) { - upload(chunks.shift()); - } else { - this$1.completeAll(xhr); - } - - }, - function (e) { return this$1.error(e); } - ); - - }; - - upload(chunks.shift()); - - } - - } - - }; - - function match(pattern, path) { - return path.match(new RegExp(("^" + (pattern.replace(/\//g, '\\/').replace(/\*\*/g, '(\\/[^\\/]+)*').replace(/\*/g, '[^\\/]+').replace(/((?!\\))\?/g, '$1.')) + "$"), 'i')); - } - - function chunk(files, size) { - var chunks = []; - for (var i = 0; i < files.length; i += size) { - var chunk = []; - for (var j = 0; j < size; j++) { - chunk.push(files[i + j]); - } - chunks.push(chunk); - } - return chunks; - } - - function stop(e) { - e.preventDefault(); - e.stopPropagation(); - } - - var components = /*#__PURE__*/Object.freeze({ - __proto__: null, - Countdown: countdown, - Filter: filter, - Lightbox: lightbox, - LightboxPanel: LightboxPanel, - Notification: notification, - Parallax: parallax, - Slider: slider, - SliderParallax: sliderParallax, - Slideshow: slideshow, - SlideshowParallax: sliderParallax, - Sortable: sortable, - Tooltip: tooltip, - Upload: upload - }); - - each(components, function (component, name) { return UIkit.component(name, component); } - ); - - return UIkit; - -}))); diff --git a/docs/assets/js/uikit.min.js b/docs/assets/js/uikit.min.js deleted file mode 100644 index 5c2f15162b..0000000000 --- a/docs/assets/js/uikit.min.js +++ /dev/null @@ -1,3 +0,0 @@ -/*! UIkit 3.7.0 | https://www.getuikit.com | (c) 2014 - 2021 YOOtheme | MIT License */ - -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define("uikit",e):(t="undefined"!=typeof globalThis?globalThis:t||self).UIkit=e()}(this,function(){"use strict";var t=Object.prototype,n=t.hasOwnProperty;function c(t,e){return n.call(t,e)}var e=/\B([A-Z])/g,d=rt(function(t){return t.replace(e,"-$1").toLowerCase()}),i=/-(\w)/g,f=rt(function(t){return t.replace(i,r)}),p=rt(function(t){return t.length?r(0,t.charAt(0))+t.slice(1):""});function r(t,e){return e?e.toUpperCase():""}var o=String.prototype,s=o.startsWith||function(t){return 0===this.lastIndexOf(t,0)};function g(t,e){return s.call(t,e)}var a=o.endsWith||function(t){return this.substr(-t.length)===t};function u(t,e){return a.call(t,e)}var h=Array.prototype,l=function(t,e){return!!~this.indexOf(t,e)},m=o.includes||l,v=h.includes||l;function w(t,e){return t&&(z(t)?m:v).call(t,e)}var b=h.findIndex||function(t){for(var e=arguments,n=0;n=e.left&&t.y<=e.bottom&&t.y>=e.top}var nt={ratio:function(t,e,n){var i="width"===e?"height":"width",r={};return r[i]=t[e]?Math.round(n*t[i]/t[e]):t[i],r[e]=n,r},contain:function(n,i){var r=this;return G(n=Y({},n),function(t,e){return n=n[e]>i[e]?r.ratio(n,e,i[e]):n}),n},cover:function(n,i){var r=this;return G(n=this.contain(n,i),function(t,e){return n=n[e]")&&(e=e.slice(1)),_(t)?zt.call(t,e):W(t).map(function(t){return Nt(t,e)}).filter(Boolean)}function Bt(t,e){return z(e)?Mt(t,e)||!!Nt(t,e):t===e||(T(e)?e.documentElement:F(e)).contains(F(t))}function Dt(t,e){for(var n=[];t=Tt(t);)e&&!Mt(t,e)||n.push(t);return n}function Ot(t,e){t=(t=F(t))?W(t.children):[];return e?Ct(t,e):t}function Pt(t,e){return e?W(t).indexOf(F(e)):Ot(Tt(t)).indexOf(t)}function Ht(t,e){return Ft(t,jt(t,e))}function Lt(t,e){return Wt(t,jt(t,e))}function jt(t,e){return void 0===e&&(e=document),z(t)&&Ut(t)||T(e)?e:e.ownerDocument}function Ft(t,e){return F(Vt(t,e,"querySelector"))}function Wt(t,e){return W(Vt(t,e,"querySelectorAll"))}function Vt(t,r,e){if(void 0===r&&(r=document),!t||!z(t))return t;t=t.replace(qt,"$1 *"),Ut(t)&&(t=Xt(t).map(function(t){var e,n,i=r;return"!"===t[0]&&(n=t.substr(1).trim().split(" "),i=Nt(Tt(r),n[0]),t=n.slice(1).join(" ").trim()),"-"===t[0]&&(e=t.substr(1).trim().split(" "),i=Mt(n=(i||r).previousElementSibling,t.substr(1))?n:null,t=e.slice(1).join(" ")),i?function(t){var e=[];for(;t.parentNode;){if(t.id){e.unshift("#"+Kt(t.id));break}var n=t.tagName;"HTML"!==n&&(n+=":nth-child("+(Pt(t)+1)+")"),e.unshift(n),t=t.parentNode}return e.join(" > ")}(i)+" "+t:null}).filter(Boolean).join(","),r=document);try{return r[e](t)}catch(t){return null}}var Rt=/(^|[^\\],)\s*[!>+~-]/,qt=/([!>+~-])(?=\s+[!>+~-]|\s*$)/g,Ut=rt(function(t){return t.match(Rt)}),Yt=/.*?[^\\](?:,|$)/g,Xt=rt(function(t){return t.match(Yt).map(function(t){return t.replace(/,$/,"").trim()})});var Gt=ct&&window.CSS&&CSS.escape||function(t){return t.replace(/([^\x7f-\uFFFF\w-])/g,function(t){return"\\"+t})};function Kt(t){return z(t)?Gt.call(null,t):""}function Jt(){for(var t=[],e=arguments.length;e--;)t[e]=arguments[e];var n,i,r=ne(t),o=r[0],s=r[1],a=r[2],u=r[3],c=r[4],o=se(o);return 1]*>/,Ce=/^<(\w+)\s*\/?>(?:<\/\1>)?$/;function _e(t){var e=Ce.exec(t);if(e)return document.createElement(e[1]);e=document.createElement("div");return Te.test(t)?e.insertAdjacentHTML("beforeend",t.trim()):e.textContent=t,1qn(t))})).reverse()}function Rn(t){return t===Un(t)?window:t}function qn(t){return(t===Un(t)?document.documentElement:t).clientHeight}function Un(t){t=V(t).document;return t.scrollingElement||t.documentElement}var Yn={width:["x","left","right"],height:["y","top","bottom"]};function Xn(t,e,h,l,d,n,i,r){h=Kn(h),l=Kn(l);var f={element:h,target:l};if(!t||!e)return f;var o,p=sn(t),m=sn(e),g=m;return Gn(g,h,p,-1),Gn(g,l,m,1),d=Jn(d,p.width,p.height),n=Jn(n,m.width,m.height),d.x+=n.x,d.y+=n.y,g.left+=d.x,g.top+=d.y,i&&(o=Vn(t).map(Rn),r&&!w(o,r)&&o.unshift(r),o=o.map(function(t){return sn(t)}),G(Yn,function(t,s){var a=t[0],u=t[1],c=t[2];!0!==i&&!w(i,a)||o.some(function(n){var t=h[a]===u?-p[s]:h[a]===c?p[s]:0,e=l[a]===u?m[s]:l[a]===c?-m[s]:0;if(g[u]n[c]){var i=p[s]/2,r="center"===l[a]?-m[s]/2:0;return"center"===h[a]&&(o(i,r)||o(-i,-r))||o(t,e)}function o(e,t){t=L((g[u]+e+t-2*d[a]).toFixed(4));if(t>=n[u]&&t+p[s]<=n[c])return g[u]=t,["element","target"].forEach(function(t){f[t][a]=e?f[t][a]===Yn[s][1]?Yn[s][2]:Yn[s][1]:f[t][a]}),!0}})})),sn(t,g),f}function Gn(r,o,s,a){G(Yn,function(t,e){var n=t[0],i=t[1],t=t[2];o[n]===t?r[i]+=s[e]*a:"center"===o[n]&&(r[i]+=s[e]*a/2)})}function Kn(t){var e=/left|center|right/,n=/top|center|bottom/;return 1===(t=(t||"").split(" ")).length&&(t=e.test(t[0])?t.concat("center"):n.test(t[0])?["center"].concat(t):["center","center"]),{x:e.test(t[0])?t[0]:"center",y:n.test(t[1])?t[1]:"center"}}function Jn(t,e,n){var i=(t||"").split(" "),t=i[0],i=i[1];return{x:t?L(t)*(u(t,"%")?e/100:1):0,y:i?L(i)*(u(i,"%")?n/100:1):0}}var Zn=Object.freeze({__proto__:null,ajax:me,getImage:ge,transition:Je,Transition:Ze,animate:tn,Animation:nn,attr:ot,hasAttr:st,removeAttr:at,data:ut,addClass:Be,removeClass:De,removeClasses:Oe,replaceClass:Pe,hasClass:He,toggleClass:Le,dimensions:on,offset:sn,position:an,offsetPosition:un,height:cn,width:hn,boxModelAdjust:dn,flipPosition:fn,toPx:pn,ready:function(t){var e;"loading"===document.readyState?e=Jt(document,"DOMContentLoaded",function(){e(),t()}):t()},empty:ve,html:we,prepend:function(e,t){return(e=Me(e)).hasChildNodes()?ke(t,function(t){return e.insertBefore(t,e.firstChild)}):be(e,t)},append:be,before:xe,after:ye,remove:$e,wrapAll:Se,wrapInner:Ie,unwrap:Ee,fragment:_e,apply:Ae,$:Me,$$:ze,inBrowser:ct,isIE:ht,isRtl:lt,hasTouch:pt,pointerDown:mt,pointerMove:gt,pointerUp:vt,pointerEnter:wt,pointerLeave:bt,pointerCancel:xt,on:Jt,off:Zt,once:Qt,trigger:te,createEvent:ee,toEventTargets:se,isTouch:ae,getEventPos:ue,fastdom:gn,isVoidElement:kt,isVisible:$t,selInput:St,isInput:It,isFocusable:Et,parent:Tt,filter:Ct,matches:Mt,closest:Nt,within:Bt,parents:Dt,children:Ot,index:Pt,hasOwn:c,hyphenate:d,camelize:f,ucfirst:p,startsWith:g,endsWith:u,includes:w,findIndex:x,isArray:y,isFunction:k,isObject:$,isPlainObject:I,isWindow:E,isDocument:T,isNode:C,isElement:_,isBoolean:M,isString:z,isNumber:N,isNumeric:B,isEmpty:D,isUndefined:O,toBoolean:P,toNumber:H,toFloat:L,toArray:j,toNode:F,toNodes:W,toWindow:V,toMs:R,isEqual:q,swap:U,assign:Y,last:X,each:G,sortBy:K,uniqueBy:J,clamp:Z,noop:Q,intersectRect:tt,pointInRect:et,Dimensions:nt,getIndex:it,memoize:rt,MouseTracker:kn,mergeOptions:En,parseOptions:Tn,play:Cn,pause:_n,mute:An,positionAt:Xn,Promise:he,Deferred:ce,query:Ht,queryAll:Lt,find:Ft,findAll:Wt,escape:Kt,css:Re,getCssVar:Xe,propName:Ge,isInView:Ln,scrollTop:jn,scrollIntoView:Fn,scrolledOver:Wn,scrollParents:Vn,getViewport:Rn,getViewportClientHeight:qn});function Qn(t){this._init(t)}var ti,ei,ni,ii,ri,oi,si,ai,ui,ci=rt(function(t){return!(!g(t,"uk-")&&!g(t,"data-uk-"))&&f(t.replace("data-uk-","").replace("uk-",""))});function hi(t,e){if(t)for(var n in t)t[n]._connected&&t[n]._callUpdate(e)}function li(t,e){var n={},i=t.args;void 0===i&&(i=[]);var r,o=t.props,s=t.el;if(!(o=void 0===o?{}:o))return n;for(r in o){var a=d(r),u=ut(s,a);O(u)||(u=o[r]===Boolean&&""===u||fi(o[r],u),("target"!==a||u&&!g(u,"_"))&&(n[r]=u))}var c,h=Tn(ut(s,e),i);for(c in h){var l=f(c);void 0!==o[l]&&(n[l]=fi(o[l],h[c]))}return n}function di(e,n,i){var t=(n=!I(n)?{name:i,handler:n}:n).name,r=n.el,o=n.handler,s=n.capture,a=n.passive,u=n.delegate,c=n.filter,h=n.self,r=k(r)?r.call(e):r||e.$el;y(r)?r.forEach(function(t){return di(e,Y({},n,{el:t}),i)}):!r||c&&!c.call(e)||e._events.push(Jt(r,t,u?z(u)?u:u.call(e):null,z(o)?e[o]:o.bind(e),{passive:a,capture:s,self:h}))}function fi(t,e){return t===Boolean?P(e):t===Number?H(e):"list"===t?y(n=e)?n:z(n)?n.split(/,(?![^(]*\))/).map(function(t){return B(t)?H(t):P(t.trim())}):[n]:t?t(e):e;var n}Qn.util=Zn,Qn.data="__uikit__",Qn.prefix="uk-",Qn.options={},Qn.version="3.7.0",ni=(ti=Qn).data,ti.use=function(t){if(!t.installed)return t.call(null,this),t.installed=!0,this},ti.mixin=function(t,e){(e=(z(e)?ti.component(e):e)||this).options=En(e.options,t)},ti.extend=function(t){t=t||{};function e(t){this._init(t)}return((e.prototype=Object.create(this.prototype)).constructor=e).options=En(this.options,t),e.super=this,e.extend=this.extend,e},ti.update=function(t,e){Dt(t=t?F(t):document.body).reverse().forEach(function(t){return hi(t[ni],e)}),Ae(t,function(t){return hi(t[ni],e)})},Object.defineProperty(ti,"container",{get:function(){return ei||document.body},set:function(t){ei=Me(t)}}),(ii=Qn).prototype._callHook=function(t){var e=this,t=this.$options[t];t&&t.forEach(function(t){return t.call(e)})},ii.prototype._callConnected=function(){this._connected||(this._data={},this._computeds={},this._initProps(),this._callHook("beforeConnect"),this._connected=!0,this._initEvents(),this._initObservers(),this._callHook("connected"),this._callUpdate())},ii.prototype._callDisconnected=function(){this._connected&&(this._callHook("beforeDisconnect"),this._disconnectObservers(),this._unbindEvents(),this._callHook("disconnected"),this._connected=!1,delete this._watch)},ii.prototype._callUpdate=function(t){var e=this;void 0===t&&(t="update"),this._connected&&("update"!==t&&"resize"!==t||this._callWatches(),this.$options.update&&(this._updates||(this._updates=new Set,gn.read(function(){e._connected&&!function(i){for(var r=this,o=this.$options.update,t=0;t *",active:!1,animation:[!0],collapsible:!0,multiple:!1,clsOpen:"uk-open",toggle:"> .uk-accordion-title",content:"> .uk-accordion-content",transition:"ease",offset:0},computed:{items:{get:function(t,e){return ze(t.targets,e)},watch:function(t,e){var n=this;t.forEach(function(t){return wi(Me(n.content,t),!He(t,n.clsOpen))}),e||He(t,this.clsOpen)||(t=!1!==this.active&&t[Number(this.active)]||!this.collapsible&&t[0])&&this.toggle(t,!1)},immediate:!0},toggles:function(t){var e=t.toggle;return this.items.map(function(t){return Me(e,t)})}},events:[{name:"click",delegate:function(){return this.targets+" "+this.$props.toggle},handler:function(t){t.preventDefault(),this.toggle(Pt(this.toggles,t.current))}}],methods:{toggle:function(t,r){var o=this,e=[this.items[it(t,this.items)]],t=Ct(this.items,"."+this.clsOpen);this.multiple||w(t,e[0])||(e=e.concat(t)),!this.collapsible&&t.length<2&&!Ct(e,":not(."+this.clsOpen+")").length||e.forEach(function(t){return o.toggleElement(t,!He(t,o.clsOpen),function(e,n){Le(e,o.clsOpen,n),ot(Me(o.$props.toggle,e),"aria-expanded",n);var i=Me((e._wrapper?"> * ":"")+o.content,e);if(!1!==r&&o.hasTransition)return e._wrapper||(e._wrapper=Se(i,"")),wi(i,!1),gi(o)(e._wrapper,n).then(function(){var t;wi(i,!n),delete e._wrapper,Ee(i),n&&(Ln(t=Me(o.$props.toggle,e))||Fn(t,{offset:o.offset}))});wi(i,!n)})})}}};function wi(t,e){t&&(t.hidden=e)}var bi={mixins:[pi,mi],args:"animation",props:{close:String},data:{animation:[!0],selClose:".uk-alert-close",duration:150,hideProps:Y({opacity:0},mi.data.hideProps)},events:[{name:"click",delegate:function(){return this.selClose},handler:function(t){t.preventDefault(),this.close()}}],methods:{close:function(){var t=this;this.toggleElement(this.$el).then(function(){return t.$destroy(!0)})}}},xi={args:"autoplay",props:{automute:Boolean,autoplay:Boolean},data:{automute:!1,autoplay:!0},computed:{inView:function(t){return"inview"===t.autoplay}},connected:function(){this.inView&&!st(this.$el,"preload")&&(this.$el.preload="none"),this.automute&&An(this.$el)},update:{read:function(){return{visible:$t(this.$el)&&"hidden"!==Re(this.$el,"visibility"),inView:this.inView&&Ln(this.$el)}},write:function(t){var e=t.visible,t=t.inView;!e||this.inView&&!t?_n(this.$el):(!0===this.autoplay||this.inView&&t)&&Cn(this.$el)},events:["resize","scroll"]}},yi={mixins:[pi,xi],props:{width:Number,height:Number},data:{automute:!0},update:{read:function(){var t=this.$el,e=function(t){for(;t=Tt(t);)if("static"!==Re(t,"position"))return t}(t)||Tt(t),n=e.offsetHeight,e=e.offsetWidth,n=nt.cover({width:this.width||t.naturalWidth||t.videoWidth||t.clientWidth,height:this.height||t.naturalHeight||t.videoHeight||t.clientHeight},{width:e+(e%2?1:0),height:n+(n%2?1:0)});return!(!n.width||!n.height)&&n},write:function(t){var e=t.height,t=t.width;Re(this.$el,{height:e,width:t})},events:["resize"]}};var ki,$i={props:{container:Boolean},data:{container:!0},computed:{container:function(t){t=t.container;return!0===t&&this.$container||t&&Me(t)}}},Si={props:{pos:String,offset:null,flip:Boolean,clsPos:String},data:{pos:"bottom-"+(lt?"right":"left"),flip:!0,offset:!1,clsPos:""},computed:{pos:function(t){t=t.pos;return(t+(w(t,"-")?"":"-center")).split("-")},dir:function(){return this.pos[0]},align:function(){return this.pos[1]}},methods:{positionAt:function(t,e,n){Oe(t,this.clsPos+"-(top|bottom|left|right)(-[a-z]+)?");var i,r=this.offset,o=this.getAxis();B(r)||(r=(i=Me(r))?sn(i)["x"===o?"left":"top"]-sn(e)["x"===o?"right":"bottom"]:0);r=Xn(t,e,"x"===o?fn(this.dir)+" "+this.align:this.align+" "+fn(this.dir),"x"===o?this.dir+" "+this.align:this.align+" "+this.dir,"x"===o?""+("left"===this.dir?-r:r):" "+("top"===this.dir?-r:r),null,this.flip,n).target,n=r.x,r=r.y;this.dir="x"===o?n:r,this.align="x"===o?r:n,Le(t,this.clsPos+"-"+this.dir+"-"+this.align,!1===this.offset)},getAxis:function(){return"top"===this.dir||"bottom"===this.dir?"y":"x"}}},Ii={mixins:[$i,Si,mi],args:"pos",props:{mode:"list",toggle:Boolean,boundary:Boolean,boundaryAlign:Boolean,delayShow:Number,delayHide:Number,clsDrop:String},data:{mode:["click","hover"],toggle:"- *",boundary:!0,boundaryAlign:!1,delayShow:0,delayHide:800,clsDrop:!1,animation:["uk-animation-fade"],cls:"uk-open",container:!1},computed:{boundary:function(t,e){t=t.boundary;return!0===t?window:Ht(t,e)},clsDrop:function(t){return t.clsDrop||"uk-"+this.$options.name},clsPos:function(){return this.clsDrop}},created:function(){this.tracker=new kn},connected:function(){Be(this.$el,this.clsDrop),this.toggle&&!this.target&&(this.target=this.$create("toggle",Ht(this.toggle,this.$el),{target:this.$el,mode:this.mode}))},disconnected:function(){this.isActive()&&(ki=null)},events:[{name:"click",delegate:function(){return"."+this.clsDrop+"-close"},handler:function(t){t.preventDefault(),this.hide(!1)}},{name:"click",delegate:function(){return'a[href^="#"]'},handler:function(t){var e=t.defaultPrevented,t=t.current.hash;e||!t||Bt(t,this.$el)||this.hide(!1)}},{name:"beforescroll",handler:function(){this.hide(!1)}},{name:"toggle",self:!0,handler:function(t,e){t.preventDefault(),this.isToggled()?this.hide(!1):this.show(e.$el,!1)}},{name:"toggleshow",self:!0,handler:function(t,e){t.preventDefault(),this.show(e.$el)}},{name:"togglehide",self:!0,handler:function(t){t.preventDefault(),this.hide()}},{name:wt+" focusin",filter:function(){return w(this.mode,"hover")},handler:function(t){ae(t)||this.clearTimers()}},{name:bt+" focusout",filter:function(){return w(this.mode,"hover")},handler:function(t){!ae(t)&&t.relatedTarget&&this.hide()}},{name:"toggled",self:!0,handler:function(t,e){e&&(this.clearTimers(),this.position())}},{name:"show",self:!0,handler:function(){var r=this;(ki=this).tracker.init(),Qt(this.$el,"hide",Jt(document,mt,function(t){var i=t.target;return!Bt(i,r.$el)&&Qt(document,vt+" "+xt+" scroll",function(t){var e=t.defaultPrevented,n=t.type,t=t.target;e||n!==vt||i!==t||r.target&&Bt(i,r.target)||r.hide(!1)},!0)}),{self:!0}),Qt(this.$el,"hide",Jt(document,"keydown",function(t){27===t.keyCode&&r.hide(!1)}),{self:!0})}},{name:"beforehide",self:!0,handler:function(){this.clearTimers()}},{name:"hide",handler:function(t){t=t.target;this.$el===t?(ki=this.isActive()?null:ki,this.tracker.cancel()):ki=null===ki&&Bt(t,this.$el)&&this.isToggled()?this:ki}}],update:{write:function(){this.isToggled()&&!He(this.$el,this.clsEnter)&&this.position()},events:["resize"]},methods:{show:function(t,e){var n,i=this;if(void 0===t&&(t=this.target),void 0===e&&(e=!0),this.isToggled()&&t&&this.target&&t!==this.target&&this.hide(!1),this.target=t,this.clearTimers(),!this.isActive()){if(ki){if(e&&ki.isDelaying)return void(this.showTimer=setTimeout(this.show,10));for(;ki&&n!==ki&&!Bt(this.$el,ki.$el);)(n=ki).hide(!1)}this.container&&Tt(this.$el)!==this.container&&be(this.container,this.$el),this.showTimer=setTimeout(function(){return i.toggleElement(i.$el,!0)},e&&this.delayShow||0)}},hide:function(t){var e=this;void 0===t&&(t=!0);function n(){return e.toggleElement(e.$el,!1,!1)}var i,r;this.clearTimers(),this.isDelaying=(i=this.$el,r=[],Ae(i,function(t){return"static"!==Re(t,"position")&&r.push(t)}),r.some(function(t){return e.tracker.movesTo(t)})),t&&this.isDelaying?this.hideTimer=setTimeout(this.hide,50):t&&this.delayHide?this.hideTimer=setTimeout(n,this.delayHide):n()},clearTimers:function(){clearTimeout(this.showTimer),clearTimeout(this.hideTimer),this.showTimer=null,this.hideTimer=null,this.isDelaying=!1},isActive:function(){return ki===this},position:function(){De(this.$el,this.clsDrop+"-stack"),Le(this.$el,this.clsDrop+"-boundary",this.boundaryAlign);var t,e=sn(this.boundary),n=this.boundaryAlign?e:sn(this.target);"justify"===this.align?(t="y"===this.getAxis()?"width":"height",Re(this.$el,t,n[t])):this.boundary&&this.$el.offsetWidth>Math.max(e.right-n.left,n.right-e.left)&&Be(this.$el,this.clsDrop+"-stack"),this.positionAt(this.$el,this.boundaryAlign?this.boundary:this.target,this.boundary)}}};var Ei={mixins:[pi],args:"target",props:{target:Boolean},data:{target:!1},computed:{input:function(t,e){return Me(St,e)},state:function(){return this.input.nextElementSibling},target:function(t,e){t=t.target;return t&&(!0===t&&Tt(this.input)===e&&this.input.nextElementSibling||Ht(t,e))}},update:function(){var t,e,n=this.target,i=this.input;!n||n[e=It(n)?"value":"textContent"]!==(i=i.files&&i.files[0]?i.files[0].name:Mt(i,"select")&&(t=ze("option",i).filter(function(t){return t.selected})[0])?t.textContent:i.value)&&(n[e]=i)},events:[{name:"change",handler:function(){this.$update()}},{name:"reset",el:function(){return Nt(this.$el,"form")},handler:function(){this.$update()}}]},Ti={update:{read:function(t){var e=Ln(this.$el);if(!e||t.isInView===e)return!1;t.isInView=e},write:function(){this.$el.src=""+this.$el.src},events:["scroll","resize"]}},Ci={props:{margin:String,firstColumn:Boolean},data:{margin:"uk-margin-small-top",firstColumn:"uk-first-column"},update:{read:function(){var t=_i(this.$el.children);return{rows:t,columns:function(t){for(var e=[],n=0;n=c[n]-1&&s[e]!==c[e]){i.push([o]);break}if(s[n]-1>c[e]||s[e]===c[e]){u.push(o);break}if(0===a){i.unshift([o]);break}}}return i}function Mi(t,e){var n=t.offsetTop,i=t.offsetLeft,r=t.offsetHeight,o=t.offsetWidth;return(e=void 0===e?!1:e)&&(n=(t=un(t))[0],i=t[1]),{top:n,left:i,bottom:n+r,right:i+o}}var zi={extends:Ci,mixins:[pi],name:"grid",props:{masonry:Boolean,parallax:Number},data:{margin:"uk-grid-margin",clsStack:"uk-grid-stack",masonry:!1,parallax:0},connected:function(){this.masonry&&Be(this.$el,"uk-flex-top uk-flex-wrap-top")},update:[{write:function(t){t=t.columns;Le(this.$el,this.clsStack,t.length<2)},events:["resize"]},{read:function(t){var e=t.columns,n=t.rows;if(!e.length||!this.masonry&&!this.parallax||Ni(this.$el))return t.translates=!1;var i,r,o=!1,s=Ot(this.$el),a=e.map(function(t){return t.reduce(function(t,e){return t+e.offsetHeight},0)}),u=(t=s,i=this.margin,L((s=t.filter(function(t){return He(t,i)})[0])?Re(s,"marginTop"):Re(t[0],"paddingLeft"))*(n.length-1)),c=Math.max.apply(Math,a)+u;this.masonry&&(e=e.map(function(t){return K(t,"offsetTop")}),t=e,r=n.map(function(t){return Math.max.apply(Math,t.map(function(t){return t.offsetHeight}))}),o=t.map(function(n){var i=0;return n.map(function(t,e){return i+=e?r[e-1]-n[e-1].offsetHeight:0})}));var h=Math.abs(this.parallax);return{padding:h=h&&a.reduce(function(t,e,n){return Math.max(t,e+u+(n%2?h:h/8)-c)},0),columns:e,translates:o,height:o?c:""}},write:function(t){var e=t.height,t=t.padding;Re(this.$el,"paddingBottom",t||""),!1!==e&&Re(this.$el,"height",e)},events:["resize"]},{read:function(t){t=t.height;return!Ni(this.$el)&&{scrolled:!!this.parallax&&Wn(this.$el,t?t-cn(this.$el):0)*Math.abs(this.parallax)}},write:function(t){var e=t.columns,i=t.scrolled,r=t.translates;!1===i&&!r||e.forEach(function(t,n){return t.forEach(function(t,e){return Re(t,"transform",i||r?"translateY("+((r&&-r[n][e])+(i?n%2?i:i/8:0))+"px)":"")})})},events:["scroll","resize"]}]};function Ni(t){return Ot(t).some(function(t){return"absolute"===Re(t,"position")})}var Bi=ht?{props:{selMinHeight:String},data:{selMinHeight:!1,forceHeight:!1},computed:{elements:function(t,e){t=t.selMinHeight;return t?ze(t,e):[e]}},update:[{read:function(){Re(this.elements,"height","")},order:-5,events:["resize"]},{write:function(){var n=this;this.elements.forEach(function(t){var e=L(Re(t,"minHeight"));e&&(n.forceHeight||Math.round(e+dn(t,"height","content-box"))>=t.offsetHeight)&&Re(t,"height",e)})},order:5,events:["resize"]}]}:{},Di={mixins:[Bi],args:"target",props:{target:String,row:Boolean},data:{target:"> *",row:!0,forceHeight:!0},computed:{elements:function(t,e){return ze(t.target,e)}},update:{read:function(){return{rows:(this.row?_i(this.elements):[this.elements]).map(Oi)}},write:function(t){t.rows.forEach(function(t){var n=t.heights;return t.elements.forEach(function(t,e){return Re(t,"minHeight",n[e])})})},events:["resize"]}};function Oi(t){if(t.length<2)return{heights:[""],elements:t};var n=t.map(Pi),i=Math.max.apply(Math,n),e=t.some(function(t){return t.style.minHeight}),r=t.some(function(t,e){return!t.style.minHeight&&n[e]"}return Wi[t][e]}(t,e)||t);return(t=Me(t.substr(t.indexOf("/g,Wi={};function Vi(t){return Math.ceil(Math.max.apply(Math,[0].concat(ze("[stroke]",t).map(function(t){try{return t.getTotalLength()}catch(t){return 0}}))))}function Ri(t,e){return qi(t)&&qi(e)&&Ui(t)===Ui(e)}function qi(t){return t&&"svg"===t.tagName}function Ui(t){return(t.innerHTML||(new XMLSerializer).serializeToString(t).replace(/(.*?)<\/svg>/g,"$1")).replace(/\s/g,"")}var Yi={spinner:'',totop:'',marker:'',"close-icon":'',"close-large":'',"navbar-toggle-icon":'',"overlay-icon":'',"pagination-next":'',"pagination-previous":'',"search-icon":'',"search-large":'',"search-navbar":'',"slidenav-next":'',"slidenav-next-large":'',"slidenav-previous":'',"slidenav-previous-large":''},Xi={install:function(r){r.icon.add=function(t,e){var n,i=z(t)?((n={})[t]=e,n):t;G(i,function(t,e){Yi[e]=t,delete tr[e]}),r._initialized&&Ae(document.body,function(t){return G(r.getComponents(t),function(t){t.$options.isIcon&&t.icon in i&&t.$reset()})})}},extends:Li,args:"icon",props:["icon"],data:{include:["focusable"]},isIcon:!0,beforeConnect:function(){Be(this.$el,"uk-icon")},methods:{getSvg:function(){var t=function(t){if(!Yi[t])return null;tr[t]||(tr[t]=Me((Yi[function(t){return lt?U(U(t,"left","right"),"previous","next"):t}(t)]||Yi[t]).trim()));return tr[t].cloneNode(!0)}(this.icon);return t?he.resolve(t):he.reject("Icon not found.")}}},Gi={args:!1,extends:Xi,data:function(t){return{icon:d(t.constructor.options.name)}},beforeConnect:function(){Be(this.$el,this.$name)}},Ki={extends:Gi,beforeConnect:function(){Be(this.$el,"uk-slidenav")},computed:{icon:function(t,e){t=t.icon;return He(e,"uk-slidenav-large")?t+"-large":t}}},Ji={extends:Gi,computed:{icon:function(t,e){t=t.icon;return He(e,"uk-search-icon")&&Dt(e,".uk-search-large").length?"search-large":Dt(e,".uk-search-navbar").length?"search-navbar":t}}},Zi={extends:Gi,computed:{icon:function(){return"close-"+(He(this.$el,"uk-close-large")?"large":"icon")}}},Qi={extends:Gi,connected:function(){var e=this;this.svg.then(function(t){return t&&1!==e.ratio&&Re(Me("circle",t),"strokeWidth",1/e.ratio)})}},tr={};var er={args:"dataSrc",props:{dataSrc:String,dataSrcset:Boolean,sizes:String,width:Number,height:Number,offsetTop:String,offsetLeft:String,target:String},data:{dataSrc:"",dataSrcset:!1,sizes:!1,width:!1,height:!1,offsetTop:"50vh",offsetLeft:"50vw",target:!1},computed:{cacheKey:function(t){t=t.dataSrc;return this.$name+"."+t},width:function(t){var e=t.width,t=t.dataWidth;return e||t},height:function(t){var e=t.height,t=t.dataHeight;return e||t},sizes:function(t){var e=t.sizes,t=t.dataSizes;return e||t},isImg:function(t,e){return ur(e)},target:{get:function(t){t=t.target;return[this.$el].concat(Lt(t,this.$el))},watch:function(){this.observe()}},offsetTop:function(t){return pn(t.offsetTop,"height")},offsetLeft:function(t){return pn(t.offsetLeft,"width")}},connected:function(){window.IntersectionObserver?(hr[this.cacheKey]?nr(this.$el,hr[this.cacheKey],this.dataSrcset,this.sizes):this.isImg&&this.width&&this.height&&nr(this.$el,function(t,e,n){n&&(n=nt.ratio({width:t,height:e},"width",pn(rr(n))),t=n.width,e=n.height);return'data:image/svg+xml;utf8,'}(this.width,this.height,this.sizes)),this.observer=new IntersectionObserver(this.load,{rootMargin:this.offsetTop+"px "+this.offsetLeft+"px"}),requestAnimationFrame(this.observe)):nr(this.$el,this.dataSrc,this.dataSrcset,this.sizes)},disconnected:function(){this.observer&&this.observer.disconnect()},update:{read:function(t){var e=this,t=t.image;return!!this.observer&&(t||"complete"!==document.readyState||this.load(this.observer.takeRecords()),!this.isImg&&void(t&&t.then(function(t){return t&&""!==t.currentSrc&&nr(e.$el,cr(t))})))},write:function(t){var e,n,i;this.dataSrcset&&1!==window.devicePixelRatio&&(!(n=Re(this.$el,"backgroundSize")).match(/^(auto\s?)+$/)&&L(n)!==t.bgSize||(t.bgSize=(e=this.dataSrcset,n=this.sizes,i=pn(rr(n)),(e=(e.match(ar)||[]).map(L).sort(function(t,e){return t-e})).filter(function(t){return i<=t})[0]||e.pop()||""),Re(this.$el,"backgroundSize",t.bgSize+"px")))},events:["resize"]},methods:{load:function(t){var e=this;t.some(function(t){return O(t.isIntersecting)||t.isIntersecting})&&(this._data.image=ge(this.dataSrc,this.dataSrcset,this.sizes).then(function(t){return nr(e.$el,cr(t),t.srcset,t.sizes),hr[e.cacheKey]=cr(t),t},function(t){return te(e.$el,new t.constructor(t.type,t))}),this.observer.disconnect())},observe:function(){var e=this;this._connected&&!this._data.image&&this.target.forEach(function(t){return e.observer.observe(t)})}}};function nr(t,e,n,i){ur(t)?(i&&(t.sizes=i),n&&(t.srcset=n),e&&(t.src=e)):e&&!w(t.style.backgroundImage,e)&&(Re(t,"backgroundImage","url("https://github.com/+Kt(e)+")"),te(t,ee("load",!1)))}var ir=/\s*(.*?)\s*(\w+|calc\(.*?\))\s*(?:,|$)/g;function rr(t){var e,n;for(ir.lastIndex=0;e=ir.exec(t);)if(!e[1]||window.matchMedia(e[1]).matches){e=g(n=e[2],"calc")?n.slice(5,-1).replace(or,function(t){return pn(t)}).replace(/ /g,"").match(sr).reduce(function(t,e){return t+ +e},0):n;break}return e||"100vw"}var or=/\d+(?:\w+|%)/g,sr=/[+-]?(\d+)/g;var ar=/\s+\d+w\s*(?:,|$)/g;function ur(t){return"IMG"===t.tagName}function cr(t){return t.currentSrc||t.src}var hr,lr="__test__";try{(hr=window.sessionStorage||{})[lr]=1,delete hr[lr]}catch(t){hr={}}var dr={props:{media:Boolean},data:{media:!1},computed:{matchMedia:function(){var t=function(t){if(z(t))if("@"===t[0])t=L(Xe("breakpoint-"+t.substr(1)));else if(isNaN(t))return t;return!(!t||isNaN(t))&&"(min-width: "+t+"px)"}(this.media);return!t||window.matchMedia(t).matches}}};var fr={mixins:[pi,dr],props:{fill:String},data:{fill:"",clsWrapper:"uk-leader-fill",clsHide:"uk-leader-hide",attrFill:"data-fill"},computed:{fill:function(t){return t.fill||Xe("leader-fill-content")}},connected:function(){var t=Ie(this.$el,'');this.wrapper=t[0]},disconnected:function(){Ee(this.wrapper.childNodes)},update:{read:function(t){var e,n=t.changed,t=e=t.width;return{width:e=Math.floor(this.$el.offsetWidth/2),fill:this.fill,changed:n||t!==e,hide:!this.matchMedia}},write:function(t){Le(this.wrapper,this.clsHide,t.hide),t.changed&&(t.changed=!1,ot(this.wrapper,this.attrFill,new Array(t.width).join(t.fill)))},events:["resize"]}},pr=[],mr={mixins:[pi,$i,mi],props:{selPanel:String,selClose:String,escClose:Boolean,bgClose:Boolean,stack:Boolean},data:{cls:"uk-open",escClose:!0,bgClose:!0,overlay:!0,stack:!1},computed:{panel:function(t,e){return Me(t.selPanel,e)},transitionElement:function(){return this.panel},bgClose:function(t){return t.bgClose&&this.panel}},beforeDisconnect:function(){this.isToggled()&&this.toggleElement(this.$el,!1,!1)},events:[{name:"click",delegate:function(){return this.selClose},handler:function(t){t.preventDefault(),this.hide()}},{name:"toggle",self:!0,handler:function(t){t.defaultPrevented||(t.preventDefault(),this.isToggled()===w(pr,this)&&this.toggle())}},{name:"beforeshow",self:!0,handler:function(t){if(w(pr,this))return!1;!this.stack&&pr.length?(he.all(pr.map(function(t){return t.hide()})).then(this.show),t.preventDefault()):pr.push(this)}},{name:"show",self:!0,handler:function(){var r=this,t=document.documentElement;hn(window)>t.clientWidth&&this.overlay&&Re(document.body,"overflowY","scroll"),this.stack&&Re(this.$el,"zIndex",L(Re(this.$el,"zIndex"))+pr.length),Be(t,this.clsPage),this.bgClose&&Qt(this.$el,"hide",Jt(document,mt,function(t){var i=t.target;X(pr)!==r||r.overlay&&!Bt(i,r.$el)||Bt(i,r.panel)||Qt(document,vt+" "+xt+" scroll",function(t){var e=t.defaultPrevented,n=t.type,t=t.target;e||n!==vt||i!==t||r.hide()},!0)}),{self:!0}),this.escClose&&Qt(this.$el,"hide",Jt(document,"keydown",function(t){27===t.keyCode&&X(pr)===r&&r.hide()}),{self:!0})}},{name:"hidden",self:!0,handler:function(){var e=this;w(pr,this)&&pr.splice(pr.indexOf(this),1),pr.length||Re(document.body,"overflowY",""),Re(this.$el,"zIndex",""),pr.some(function(t){return t.clsPage===e.clsPage})||De(document.documentElement,this.clsPage)}}],methods:{toggle:function(){return this.isToggled()?this.hide():this.show()},show:function(){var e=this;return this.container&&Tt(this.$el)!==this.container?(be(this.container,this.$el),new he(function(t){return requestAnimationFrame(function(){return e.show().then(t)})})):this.toggleElement(this.$el,!0,gr(this))},hide:function(){return this.toggleElement(this.$el,!1,gr(this))}}};function gr(t){var s=t.transitionElement,a=t._toggle;return function(r,o){return new he(function(n,i){return Qt(r,"show hide",function(){r._reject&&r._reject(),r._reject=i,a(r,o);var t=Qt(s,"transitionstart",function(){Qt(s,"transitionend transitioncancel",n,{self:!0}),clearTimeout(e)},{self:!0}),e=setTimeout(function(){t(),n()},R(Re(s,"transitionDuration")))})}).then(function(){return delete r._reject})}}var vr={install:function(t){var a=t.modal;function i(t,e,n,i){e=Y({bgClose:!1,escClose:!0,labels:a.labels},e);var r=a.dialog(t(e),e),o=new ce,s=!1;return Jt(r.$el,"submit","form",function(t){t.preventDefault(),o.resolve(i&&i(r)),s=!0,r.hide()}),Jt(r.$el,"hide",function(){return!s&&n(o)}),o.promise.dialog=r,o.promise}a.dialog=function(t,e){var n=a('
      '+t+"
      ",e);return n.show(),Jt(n.$el,"hidden",function(){return he.resolve().then(function(){return n.$destroy(!0)})},{self:!0}),n},a.alert=function(e,t){return i(function(t){t=t.labels;return'
      '+(z(e)?e:we(e))+'
      "},t,function(t){return t.resolve()})},a.confirm=function(e,t){return i(function(t){t=t.labels;return'
      '+(z(e)?e:we(e))+'
      "},t,function(t){return t.reject()})},a.prompt=function(e,n,t){return i(function(t){t=t.labels;return'
      "},t,function(t){return t.resolve(null)},function(t){return Me("input",t.$el).value})},a.labels={ok:"Ok",cancel:"Cancel"}},mixins:[mr],data:{clsPage:"uk-modal-page",selPanel:".uk-modal-dialog",selClose:".uk-modal-close, .uk-modal-close-default, .uk-modal-close-outside, .uk-modal-close-full"},events:[{name:"show",self:!0,handler:function(){He(this.panel,"uk-margin-auto-vertical")?Be(this.$el,"uk-flex"):Re(this.$el,"display","block"),cn(this.$el)}},{name:"hidden",self:!0,handler:function(){Re(this.$el,"display",""),De(this.$el,"uk-flex")}}]};o=".uk-navbar-nav > li > a, .uk-navbar-item, .uk-navbar-toggle",l={mixins:[pi,$i,Bi],props:{dropdown:String,mode:"list",align:String,offset:Number,boundary:Boolean,boundaryAlign:Boolean,clsDrop:String,delayShow:Number,delayHide:Number,dropbar:Boolean,dropbarMode:String,dropbarAnchor:Boolean,duration:Number},data:{dropdown:o,align:lt?"right":"left",clsDrop:"uk-navbar-dropdown",mode:void 0,offset:void 0,delayShow:void 0,delayHide:void 0,boundaryAlign:void 0,flip:"x",boundary:!0,dropbar:!1,dropbarMode:"slide",dropbarAnchor:!1,duration:200,forceHeight:!0,selMinHeight:o,container:!1},computed:{boundary:function(t,e){var n=t.boundary,t=t.boundaryAlign;return!0===n||t?e:n},dropbarAnchor:function(t,e){return Ht(t.dropbarAnchor,e)},pos:function(t){return"bottom-"+t.align},dropbar:{get:function(t){t=t.dropbar;return t?(t=this._dropbar||Ht(t,this.$el)||Me("+ .uk-navbar-dropbar",this.$el))||(this._dropbar=Me("
      ")):null},watch:function(t){Be(t,"uk-navbar-dropbar")},immediate:!0},dropContainer:function(t,e){return this.container||e},dropdowns:{get:function(t,e){var t=t.clsDrop,n=ze("."+t,e);return this.container!==e&&ze("."+t,this.container).forEach(function(t){return!w(n,t)&&n.push(t)}),n},watch:function(t){var e=this;this.$create("drop",t.filter(function(t){return!e.getDropdown(t)}),Y({},this.$props,{boundary:this.boundary,pos:this.pos,offset:this.dropbar||this.offset}))},immediate:!0}},disconnected:function(){this.dropbar&&$e(this.dropbar),delete this._dropbar},events:[{name:"mouseover",delegate:function(){return this.dropdown},handler:function(t){var e=t.current,t=this.getActive();t&&t.target&&!Bt(t.target,e)&&!t.tracker.movesTo(t.$el)&&t.hide(!1)}},{name:"mouseleave",el:function(){return this.dropbar},handler:function(){var t=this.getActive();t&&!this.dropdowns.some(function(t){return Mt(t,":hover")})&&t.hide()}},{name:"beforeshow",el:function(){return this.dropContainer},filter:function(){return this.dropbar},handler:function(){Tt(this.dropbar)||ye(this.dropbarAnchor||this.$el,this.dropbar)}},{name:"show",el:function(){return this.dropContainer},filter:function(){return this.dropbar},handler:function(t,e){var n=e.$el,e=e.dir;He(n,this.clsDrop)&&("slide"===this.dropbarMode&&Be(this.dropbar,"uk-navbar-dropbar-slide"),this.clsDrop&&Be(n,this.clsDrop+"-dropbar"),"bottom"===e&&this.transitionTo(n.offsetHeight+L(Re(n,"marginTop"))+L(Re(n,"marginBottom")),n))}},{name:"beforehide",el:function(){return this.dropContainer},filter:function(){return this.dropbar},handler:function(t,e){var n=e.$el,e=this.getActive();Mt(this.dropbar,":hover")&&e&&e.$el===n&&t.preventDefault()}},{name:"hide",el:function(){return this.dropContainer},filter:function(){return this.dropbar},handler:function(t,e){var n=e.$el;!He(n,this.clsDrop)||(!(e=this.getActive())||e&&e.$el===n)&&this.transitionTo(0)}}],methods:{getActive:function(){return ki&&w(ki.mode,"hover")&&Bt(ki.target,this.$el)&&ki},transitionTo:function(t,e){var n=this,i=this.dropbar,r=$t(i)?cn(i):0;return Re(e=r"),Be(Tt(this.panel),this.clsMode)),Re(document.documentElement,"overflowY",this.overlay?"hidden":""),Be(document.body,this.clsContainer,this.clsFlip),Re(document.body,"touch-action","pan-y pinch-zoom"),Re(this.$el,"display","block"),Be(this.$el,this.clsOverlay),Be(this.panel,this.clsSidebarAnimation,"reveal"!==this.mode?this.clsMode:""),cn(document.body),Be(document.body,this.clsContainerAnimation),this.clsContainerAnimation&&(wr().content+=",user-scalable=0")}},{name:"hide",self:!0,handler:function(){De(document.body,this.clsContainerAnimation),Re(document.body,"touch-action","")}},{name:"hidden",self:!0,handler:function(){var t;this.clsContainerAnimation&&((t=wr()).content=t.content.replace(/,user-scalable=0$/,"")),"reveal"===this.mode&&Ee(this.panel),De(this.panel,this.clsSidebarAnimation,this.clsMode),De(this.$el,this.clsOverlay),Re(this.$el,"display",""),De(document.body,this.clsContainer,this.clsFlip),Re(document.documentElement,"overflowY","")}},{name:"swipeLeft swipeRight",handler:function(t){this.isToggled()&&u(t.type,"Left")^this.flip&&this.hide()}}]};function wr(){return Me('meta[name="viewport"]',document.head)||be(document.head,'')}var dt={mixins:[pi],props:{selContainer:String,selContent:String},data:{selContainer:".uk-modal",selContent:".uk-modal-dialog"},computed:{container:function(t,e){return Nt(e,t.selContainer)},content:function(t,e){return Nt(e,t.selContent)}},connected:function(){Re(this.$el,"minHeight",150)},update:{read:function(){return!!(this.content&&this.container&&$t(this.$el))&&{current:L(Re(this.$el,"maxHeight")),max:Math.max(150,cn(this.container)-(on(this.content).height-cn(this.$el)))}},write:function(t){var e=t.current,t=t.max;Re(this.$el,"maxHeight",t),Math.round(e)!==Math.round(t)&&te(this.$el,"resize")},events:["resize"]}},ft={props:{offset:Number},data:{offset:0},methods:{scrollTo:function(t){var e=this;t=t&&Me(t)||document.body,te(this.$el,"beforescroll",[this,t])&&Fn(t,{offset:this.offset}).then(function(){return te(e.$el,"scrolled",[e,t])})}},events:{click:function(t){t.defaultPrevented||(t.preventDefault(),this.scrollTo("#"+Kt(decodeURIComponent((this.$el.hash||"").substr(1)))))}}},br="_ukScrollspy",_t={args:"cls",props:{cls:String,target:String,hidden:Boolean,offsetTop:Number,offsetLeft:Number,repeat:Boolean,delay:Number},data:function(){return{cls:!1,target:!1,hidden:!0,offsetTop:0,offsetLeft:0,repeat:!1,delay:0,inViewClass:"uk-scrollspy-inview"}},computed:{elements:{get:function(t,e){t=t.target;return t?ze(t,e):[e]},watch:function(t){this.hidden&&Re(Ct(t,":not(."+this.inViewClass+")"),"visibility","hidden")},immediate:!0}},disconnected:function(){var e=this;this.elements.forEach(function(t){De(t,e.inViewClass,t[br]?t[br].cls:""),delete t[br]})},update:[{read:function(t){var e=this;if(!t.update)return he.resolve().then(function(){e.$emit(),t.update=!0}),!1;this.elements.forEach(function(t){t[br]||(t[br]={cls:ut(t,"uk-scrollspy-class")||e.cls}),t[br].show=Ln(t,e.offsetTop,e.offsetLeft)})},write:function(n){var i=this;this.elements.forEach(function(t){var e=t[br];!e.show||e.inview||e.queued?!e.show&&e.inview&&!e.queued&&i.repeat&&i.toggle(t,!1):(e.queued=!0,n.promise=(n.promise||he.resolve()).then(function(){return new he(function(t){return setTimeout(t,i.delay)})}).then(function(){i.toggle(t,!0),setTimeout(function(){e.queued=!1,i.$emit()},300)}))})},events:["scroll","resize"]}],methods:{toggle:function(t,e){var n=t[br];n.off&&n.off(),Re(t,"visibility",!e&&this.hidden?"hidden":""),Le(t,this.inViewClass,e),Le(t,n.cls),/\buk-animation-/.test(n.cls)&&(n.off=Qt(t,"animationcancel animationend",function(){return Oe(t,"uk-animation-\\w*")})),te(t,e?"inview":"outview"),n.inview=e,this.$update(t)}}},pe={props:{cls:String,closest:String,scroll:Boolean,overflow:Boolean,offset:Number},data:{cls:"uk-active",closest:!1,scroll:!1,overflow:!0,offset:0},computed:{links:{get:function(t,e){return ze('a[href^="#"]',e).filter(function(t){return t.hash})},watch:function(t){this.scroll&&this.$create("scroll",t,{offset:this.offset||0})},immediate:!0},targets:function(){return ze(this.links.map(function(t){return Kt(t.hash).substr(1)}).join(","))},elements:function(t){t=t.closest;return Nt(this.links,t||"*")}},update:[{read:function(){var n=this,t=this.targets.length;if(!t||!$t(this.$el))return!1;var i=Vn(this.targets,/auto|scroll/,!0)[0],e=i.scrollTop,r=i.scrollHeight-qn(i),o=!1;return e===r?o=t-1:(this.targets.every(function(t,e){if(sn(t).top-sn(Rn(i)).top-n.offset<=0)return o=e,!0}),!1===o&&this.overflow&&(o=0)),{active:o}},write:function(t){var e=t.active,t=!1!==e&&!He(this.elements[e],this.cls);this.links.forEach(function(t){return t.blur()}),De(this.elements,this.cls),Be(this.elements[e],this.cls),t&&te(this.$el,"active",[e,this.elements[e]])},events:["scroll","resize"]}]},Zn={mixins:[pi,dr],props:{top:null,bottom:Boolean,offset:String,animation:String,clsActive:String,clsInactive:String,clsFixed:String,clsBelow:String,selTarget:String,widthElement:Boolean,showOnUp:Boolean,targetOffset:Number},data:{top:0,bottom:!1,offset:0,animation:"",clsActive:"uk-active",clsInactive:"",clsFixed:"uk-sticky-fixed",clsBelow:"uk-sticky-below",selTarget:"",widthElement:!1,showOnUp:!1,targetOffset:!1},computed:{offset:function(t){return pn(t.offset)},selTarget:function(t,e){t=t.selTarget;return t&&Me(t,e)||e},widthElement:function(t,e){return Ht(t.widthElement,e)||this.placeholder},isActive:{get:function(){return He(this.selTarget,this.clsActive)},set:function(t){t&&!this.isActive?(Pe(this.selTarget,this.clsInactive,this.clsActive),te(this.$el,"active")):t||He(this.selTarget,this.clsInactive)||(Pe(this.selTarget,this.clsActive,this.clsInactive),te(this.$el,"inactive"))}}},connected:function(){this.placeholder=Me("+ .uk-sticky-placeholder",this.$el)||Me('
      '),this.isFixed=!1,this.isActive=!1},disconnected:function(){this.isFixed&&(this.hide(),De(this.selTarget,this.clsInactive)),$e(this.placeholder),this.placeholder=null,this.widthElement=null},events:[{name:"load hashchange popstate",el:function(){return window},handler:function(){var i,r=this;!1!==this.targetOffset&&location.hash&&0this.topOffset?(nn.cancel(this.$el),nn.out(this.$el,this.animation).then(function(){return n.hide()},Q)):this.hide()):nn.inProgress(this.$el)&&cthis.top,e=Math.max(0,this.offset);B(this.bottom)&&this.scroll>this.bottom-this.offset&&(e=this.bottom-this.scroll),Re(this.$el,{position:"fixed",top:e+"px",width:this.width}),this.isActive=t,Le(this.$el,this.clsBelow,this.scroll>this.bottomOffset),Be(this.$el,this.clsFixed)}}};function xr(t,e){var n=e.$props,i=e.$el,e=e[t+"Offset"],t=n[t];if(t)return z(t)&&t.match(/^-?\d/)?e+pn(t):sn(!0===t?Tt(i):Ht(t,i)).bottom}var yr,kr,$r,lr={mixins:[mi],args:"connect",props:{connect:String,toggle:String,active:Number,swiping:Boolean},data:{connect:"~.uk-switcher",toggle:"> * > :first-child",active:0,swiping:!0,cls:"uk-active",attrItem:"uk-switcher-item"},computed:{connects:{get:function(t,e){return Lt(t.connect,e)},watch:function(t){var n=this;this.swiping&&Re(t,"touch-action","pan-y pinch-zoom");var i=this.index();this.connects.forEach(function(t){return Ot(t).forEach(function(t,e){return Le(t,n.cls,e===i)})})},immediate:!0},toggles:{get:function(t,e){return ze(t.toggle,e).filter(function(t){return!Mt(t,".uk-disabled *, .uk-disabled, [disabled]")})},watch:function(t){var e=this.index();this.show(~e?e:t[this.active]||t[0])},immediate:!0},children:function(){var t=this;return Ot(this.$el).filter(function(e){return t.toggles.some(function(t){return Bt(t,e)})})}},events:[{name:"click",delegate:function(){return this.toggle},handler:function(t){t.preventDefault(),this.show(t.current)}},{name:"click",el:function(){return this.connects},delegate:function(){return"["+this.attrItem+"],[data-"+this.attrItem+"]"},handler:function(t){t.preventDefault(),this.show(ut(t.current,this.attrItem))}},{name:"swipeRight swipeLeft",filter:function(){return this.swiping},el:function(){return this.connects},handler:function(t){t=t.type;this.show(u(t,"Left")?"next":"previous")}}],methods:{index:function(){var e=this;return x(this.children,function(t){return He(t,e.cls)})},show:function(t){var n=this,i=this.index(),r=it(this.children[it(t,this.toggles,i)],Ot(this.$el));i!==r&&(this.children.forEach(function(t,e){Le(t,n.cls,r===e),ot(n.toggles[e],"aria-expanded",r===e)}),this.connects.forEach(function(t){var e=t.children;return n.toggleElement(W(e).filter(function(t){return He(t,n.cls)}),!1,0<=i).then(function(){return n.toggleElement(e[r],!0,0<=i)})}))}}},Bi={mixins:[pi],extends:lr,props:{media:Boolean},data:{media:960,attrItem:"uk-tab-item"},connected:function(){var t=He(this.$el,"uk-tab-left")?"uk-tab-left":!!He(this.$el,"uk-tab-right")&&"uk-tab-right";t&&this.$create("toggle",this.$el,{cls:t,mode:"media",media:this.media})}},o={mixins:[dr,mi],args:"target",props:{href:String,target:null,mode:"list",queued:Boolean},data:{href:!1,target:!1,mode:"click",queued:!0},connected:function(){Et(this.$el)||ot(this.$el,"tabindex","0")},computed:{target:{get:function(t,e){var n=t.href;return(t=Lt((t=t.target)||n,e)).length&&t||[e]},watch:function(){this.updateAria()},immediate:!0}},events:[{name:wt+" "+bt+" focus blur",filter:function(){return w(this.mode,"hover")},handler:function(t){ae(t)||this.toggle("toggle"+(w([wt,"focus"],t.type)?"show":"hide"))}},{name:"click",filter:function(){return w(this.mode,"click")||pt&&w(this.mode,"hover")},handler:function(t){var e;(Nt(t.target,'a[href="#"], a[href=""]')||(e=Nt(t.target,"a[href]"))&&(!Sr(this.target,this.cls)||e.hash&&Mt(this.target,e.hash)))&&t.preventDefault(),this.toggle()}},{name:"toggled",self:!0,el:function(){return this.target},handler:function(t,e){this.updateAria(e)}}],update:{read:function(){return!(!w(this.mode,"media")||!this.media)&&{match:this.matchMedia}},write:function(t){var e=t.match,t=this.isToggled(this.target);(e?!t:t)&&this.toggle()},events:["resize"]},methods:{toggle:function(t){var n=this;if(te(this.target,t||"toggle",[this])){if(!this.queued)return this.toggleElement(this.target);var e,i=this.target.filter(function(t){return He(t,n.clsLeave)});i.length?this.target.forEach(function(t){var e=w(i,t);n.toggleElement(t,e,e)}):(e=this.target.filter(this.isToggled),this.toggleElement(e,!1).then(function(){return n.toggleElement(n.target.filter(function(t){return!w(e,t)}),!0)}))}},updateAria:function(t){ot(this.$el,"aria-expanded",M(t)?t:Sr(this.target,this.cls))}}};function Sr(t,e){return e?He(t,e.split(" ")[0]):$t(t)}function Ir(t){for(var e=t.addedNodes,n=t.removedNodes,i=0;i .uk-parent",toggle:"> a",content:"> ul"}},Navbar:l,Offcanvas:t,OverflowAuto:dt,Responsive:{props:["width","height"],connected:function(){Be(this.$el,"uk-responsive-width")},update:{read:function(){return!!($t(this.$el)&&this.width&&this.height)&&{width:hn(Tt(this.$el)),height:this.height}},write:function(t){cn(this.$el,nt.contain({height:this.height,width:this.width},t).height)},events:["resize"]}},Scroll:ft,Scrollspy:_t,ScrollspyNav:pe,Sticky:Zn,Svg:Li,Switcher:lr,Tab:Bi,Toggle:o,Video:xi,Close:Zi,Spinner:Qi,SlidenavNext:Ki,SlidenavPrevious:Ki,SearchIcon:Ji,Marker:Gi,NavbarToggleIcon:Gi,OverlayIcon:Gi,PaginationNext:Gi,PaginationPrevious:Gi,Totop:Gi}),function(t,e){return Qn.component(e,t)}),Qn.use(function(e){var t,n,i,r;ct&&(n=function(){t||(t=!0,gn.write(function(){return t=!1}),e.update(null,"resize"))},Jt(window,"load resize",n),Jt(document,"loadedmetadata load",n,!0),"ResizeObserver"in window&&new ResizeObserver(n).observe(document.documentElement),Jt(window,"scroll",function(t){i||(i=!0,gn.write(function(){return i=!1}),e.update(null,t.type))},{passive:!0,capture:!0}),r=0,Jt(document,"animationstart",function(t){t=t.target;(Re(t,"animationName")||"").match(/^uk-.*(left|right)/)&&(r++,Re(document.documentElement,"overflowX","hidden"),setTimeout(function(){--r||Re(document.documentElement,"overflowX","")},R(Re(t,"animationDuration"))+100))},!0),Jt(document,mt,function(t){var s,a;ae(t)&&(s=ue(t),a="tagName"in t.target?t.target:Tt(t.target),Qt(document,vt+" "+xt+" scroll",function(t){var e=ue(t),r=e.x,o=e.y;("scroll"!==t.type&&a&&r&&100=Math.abs(e-i)?0
      "}).join("")),e.forEach(function(t,e){return n.children[e].textContent=t}))})}},methods:{start:function(){this.stop(),this.date&&this.units.length&&(this.$update(),this.timer=setInterval(this.$update,1e3))},stop:function(){this.timer&&(clearInterval(this.timer),this.timer=null)}}};var Tr="uk-transition-leave",Cr="uk-transition-enter";function _r(t,s,a,u){void 0===u&&(u=0);var c=Ar(s,!0),h={opacity:1},l={opacity:0},e=function(t){return function(){return c===Ar(s)?t():he.reject()}},n=e(function(){return Be(s,Tr),he.all(zr(s).map(function(e,n){return new he(function(t){return setTimeout(function(){return Ze.start(e,l,a/2,"ease").then(t)},n*u)})})).then(function(){return De(s,Tr)})}),e=e(function(){var o=cn(s);return Be(s,Cr),t(),Re(Ot(s),{opacity:0}),new he(function(r){return requestAnimationFrame(function(){var t=Ot(s),e=cn(s);Re(s,"alignContent","flex-start"),cn(s,o);var n=zr(s);Re(t,l);var i=n.map(function(e,n){return new he(function(t){return setTimeout(function(){return Ze.start(e,h,a/2,"ease").then(t)},n*u)})});o!==e&&i.push(Ze.start(s,{height:e},a/2+n.length*u,"ease")),he.all(i).then(function(){De(s,Cr),c===Ar(s)&&(Re(s,{height:"",alignContent:""}),Re(t,{opacity:""}),delete s.dataset.transition),r()})})})});return(He(s,Tr)?Mr(s):He(s,Cr)?Mr(s).then(n):n()).then(e)}function Ar(t,e){return e&&(t.dataset.transition=1+Ar(t)),H(t.dataset.transition)||0}function Mr(t){return he.all(Ot(t).filter(Ze.inProgress).map(function(e){return new he(function(t){return Qt(e,"transitionend transitioncanceled",t)})}))}function zr(t){return _i(Ot(t)).reduce(function(t,e){return t.concat(K(e.filter(function(t){return Ln(t)}),"offsetLeft"))},[])}function Nr(t,d,f){return new he(function(l){return requestAnimationFrame(function(){var u=Ot(d),c=u.map(function(t){return Br(t,!0)}),h=Re(d,["height","padding"]);Ze.cancel(d),u.forEach(Ze.cancel),Dr(d),t(),u=u.concat(Ot(d).filter(function(t){return!w(u,t)})),he.resolve().then(function(){gn.flush();var n,i,r,t,e,o=Re(d,["height","padding"]),e=(n=d,r=c,t=(i=u).map(function(t,e){return!!(Tt(t)&&e in r)&&(r[e]?$t(t)?Or(t):{opacity:0}:{opacity:$t(t)?1:0})}),e=t.map(function(t,e){e=Tt(i[e])===n&&(r[e]||Br(i[e]));return!!e&&(t?"opacity"in t||(e.opacity%1?t.opacity=1:delete e.opacity):delete e.opacity,e)}),[t,e]),s=e[0],a=e[1];u.forEach(function(t,e){return a[e]&&Re(t,a[e])}),Re(d,Y({display:"block"},h)),requestAnimationFrame(function(){var t=u.map(function(t,e){return Tt(t)===d&&Ze.start(t,s[e],f,"ease")}).concat(Ze.start(d,o,f,"ease"));he.all(t).then(function(){u.forEach(function(t,e){return Tt(t)===d&&Re(t,"display",0===s[e].opacity?"none":"")}),Dr(d)},Q).then(l)})})})})}function Br(t,e){var n=Re(t,"zIndex");return!!$t(t)&&Y({display:"",opacity:e?Re(t,"opacity"):"0",pointerEvents:"none",position:"absolute",zIndex:"auto"===n?Pt(t):n},Or(t))}function Dr(t){Re(t.children,{height:"",left:"",opacity:"",pointerEvents:"",position:"",top:"",marginTop:"",marginLeft:"",transform:"",width:"",zIndex:""}),Re(t,{height:"",display:"",padding:""})}function Or(t){var e=sn(t),n=e.height,i=e.width,r=an(t),e=r.top,r=r.left,t=Re(t,["marginTop","marginLeft"]);return{top:e,left:r,height:n,width:i,marginLeft:t.marginLeft,marginTop:t.marginTop,transform:""}}Bi={props:{duration:Number,animation:Boolean},data:{duration:150,animation:"slide"},methods:{animate:function(t,e){var n=this;void 0===e&&(e=this.$el);var i=this.animation;return("fade"===i?_r:"delayed-fade"===i?function(){for(var t=[],e=arguments.length;e--;)t[e]=arguments[e];return _r.apply(void 0,t.concat([40]))}:i?Nr:function(){return t(),he.resolve()})(t,e,this.duration).then(function(){return n.$update(e,"resize")},Q)}}},o={mixins:[Bi],args:"target",props:{target:Boolean,selActive:Boolean},data:{target:null,selActive:!1,attrItem:"uk-filter-control",cls:"uk-active",duration:250},computed:{toggles:{get:function(t,e){t=t.attrItem;return ze("["+t+"],[data-"+t+"]",e)},watch:function(){var e,n=this;this.updateState(),!1!==this.selActive&&(e=ze(this.selActive,this.$el),this.toggles.forEach(function(t){return Le(t,n.cls,w(e,t))}))},immediate:!0},children:{get:function(t,e){return ze(t.target+" > *",e)},watch:function(t,e){var n;e&&(n=e,(t=t).length!==n.length||!t.every(function(t){return~n.indexOf(t)}))&&this.updateState()},immediate:!0}},events:[{name:"click",delegate:function(){return"["+this.attrItem+"],[data-"+this.attrItem+"]"},handler:function(t){t.preventDefault(),this.apply(t.current)}}],methods:{apply:function(t){var e,n,i=this.getState(),t=Hr(t,this.attrItem,this.getState());e=i,n=t,["filter","sort"].every(function(t){return q(e[t],n[t])})||this.setState(t)},getState:function(){var n=this;return this.toggles.filter(function(t){return He(t,n.cls)}).reduce(function(t,e){return Hr(e,n.attrItem,t)},{filter:{"":""},sort:[]})},setState:function(n,i){var r=this;void 0===i&&(i=!0),n=Y({filter:{"":""},sort:[]},n),te(this.$el,"beforeFilter",[this,n]),this.toggles.forEach(function(t){return Le(t,r.cls,!!function(t,e,n){var i=n.filter;void 0===i&&(i={"":""});var r=n.sort,o=r[0],s=r[1],n=Pr(t,e),r=n.filter;void 0===r&&(r="");t=n.group;void 0===t&&(t="");e=n.sort,n=n.order;void 0===n&&(n="asc");return O(e)?t in i&&r===i[t]||!r&&t&&!(t in i)&&!i[""]:o===e&&s===n}(t,r.attrItem,n))}),he.all(ze(this.target,this.$el).map(function(t){function e(){!function(t,e,n){var i=function(t){var t=t.filter,e="";return G(t,function(t){return e+=t||""}),e}(t);n.forEach(function(t){return Re(t,"display",i&&!Mt(t,i)?"none":"")});var r=t.sort,t=r[0],r=r[1];t&&(q(r=function(t,n,i){return Y([],t).sort(function(t,e){return ut(t,n).localeCompare(ut(e,n),void 0,{numeric:!0})*("asc"===i||-1)})}(n,t,r),n)||be(e,r))}(n,t,Ot(t)),r.$update(r.$el)}return i?r.animate(e,t):e()})).then(function(){return te(r.$el,"afterFilter",[r])})},updateState:function(){var t=this;gn.write(function(){return t.setState(t.getState(),!1)})}}};function Pr(t,e){return Tn(ut(t,e),["filter"])}function Hr(t,e,n){var i=Pr(t,e),r=i.filter,t=i.group,e=i.sort,i=i.order;return void 0===i&&(i="asc"),(r||O(e))&&(t?r?(delete n.filter[""],n.filter[t]=r):(delete n.filter[t],(D(n.filter)||""in n.filter)&&(n.filter={"":r||""})):n.filter={"":r||""}),O(e)||(n.sort=[e,i]),n}xi={slide:{show:function(t){return[{transform:jr(-100*t)},{transform:jr()}]},percent:Lr,translate:function(t,e){return[{transform:jr(-100*e*t)},{transform:jr(100*e*(1-t))}]}}};function Lr(t){return Math.abs(Re(t,"transform").split(",")[4]/t.offsetWidth)||0}function jr(t,e){return void 0===t&&(t=0),void 0===e&&(e="%"),t+=t?e:"",ht?"translateX("+t+")":"translate3d("+t+", 0, 0)"}function Fr(t){return"scale3d("+t+", "+t+", 1)"}var Wr=Y({},xi,{fade:{show:function(){return[{opacity:0},{opacity:1}]},percent:function(t){return 1-Re(t,"opacity")},translate:function(t){return[{opacity:1-t},{opacity:t}]}},scale:{show:function(){return[{opacity:0,transform:Fr(.8)},{opacity:1,transform:Fr(1)}]},percent:function(t){return 1-Re(t,"opacity")},translate:function(t){return[{opacity:1-t,transform:Fr(1-.2*t)},{opacity:t,transform:Fr(.8+.2*t)}]}}});function Vr(t,e,n){te(t,ee(e,!1,!1,n))}Zi={mixins:[{props:{autoplay:Boolean,autoplayInterval:Number,pauseOnHover:Boolean},data:{autoplay:!1,autoplayInterval:7e3,pauseOnHover:!0},connected:function(){this.autoplay&&this.startAutoplay()},disconnected:function(){this.stopAutoplay()},update:function(){ot(this.slides,"tabindex","-1")},events:[{name:"visibilitychange",el:function(){return document},filter:function(){return this.autoplay},handler:function(){document.hidden?this.stopAutoplay():this.startAutoplay()}}],methods:{startAutoplay:function(){var t=this;this.stopAutoplay(),this.interval=setInterval(function(){return(!t.draggable||!Me(":focus",t.$el))&&(!t.pauseOnHover||!Mt(t.$el,":hover"))&&!t.stack.length&&t.show("next")},this.autoplayInterval)},stopAutoplay:function(){this.interval&&clearInterval(this.interval)}}},{props:{draggable:Boolean},data:{draggable:!0,threshold:10},created:function(){var i=this;["start","move","end"].forEach(function(t){var n=i[t];i[t]=function(t){var e=ue(t).x*(lt?-1:1);i.prevPos=e!==i.pos?i.pos:i.prevPos,i.pos=e,n(t)}})},events:[{name:mt,delegate:function(){return this.selSlides},handler:function(t){var e;!this.draggable||!ae(t)&&(!(e=t.target).children.length&&e.childNodes.length)||Nt(t.target,St)||0this.pos,this.index=t?this.index:this.prevIndex,t&&(this.percent=1-this.percent),this.show(0
    • '}).join("")),this.navItems.concat(this.nav).forEach(function(t){return t&&(t.hidden=!n.maxIndex)}),this.updateNav()},events:["resize"]},events:[{name:"click",delegate:function(){return this.selNavItem},handler:function(t){t.preventDefault(),this.show(ut(t.current,this.attrItem))}},{name:"itemshow",handler:"updateNav"}],methods:{updateNav:function(){var n=this,i=this.getValidIndex();this.navItems.forEach(function(t){var e=ut(t,n.attrItem);Le(t,n.clsActive,H(e)===i),Le(t,"uk-invisible",n.finite&&("previous"===e&&0===i||"next"===e&&i>=n.maxIndex))})}}}],props:{clsActivated:Boolean,easing:String,index:Number,finite:Boolean,velocity:Number,selSlides:String},data:function(){return{easing:"ease",finite:!1,velocity:1,index:0,prevIndex:-1,stack:[],percent:0,clsActive:"uk-active",clsActivated:!1,Transitioner:!1,transitionOptions:{}}},connected:function(){this.prevIndex=-1,this.index=this.getValidIndex(this.$props.index),this.stack=[]},disconnected:function(){De(this.slides,this.clsActive)},computed:{duration:function(t,e){t=t.velocity;return Rr(e.offsetWidth/t)},list:function(t,e){return Me(t.selList,e)},maxIndex:function(){return this.length-1},selSlides:function(t){return t.selList+" "+(t.selSlides||"> *")},slides:{get:function(){return ze(this.selSlides,this.$el)},watch:function(){this.$reset()}},length:function(){return this.slides.length}},events:{itemshown:function(){this.$update(this.list)}},methods:{show:function(t,e){var n=this;if(void 0===e&&(e=!1),!this.dragging&&this.length){var i=this.stack,r=e?0:i.length,o=function(){i.splice(r,1),i.length&&n.show(i.shift(),!0)};if(i[e?"unshift":"push"](t),!e&&1
        '}},created:function(){var t=Me(this.template),e=Me(this.selList,t);this.items.forEach(function(){return be(e,"
      • ")}),this.$mount(be(this.container,t))},computed:{caption:function(t,e){return Me(t.selCaption,e)}},events:[{name:gt+" "+mt+" keydown",handler:"showControls"},{name:"click",self:!0,delegate:function(){return this.selSlides},handler:function(t){t.defaultPrevented||this.hide()}},{name:"shown",self:!0,handler:function(){this.showControls()}},{name:"hide",self:!0,handler:function(){this.hideControls(),De(this.slides,this.clsActive),Ze.stop(this.slides)}},{name:"hidden",self:!0,handler:function(){this.$destroy(!0)}},{name:"keyup",el:function(){return document},handler:function(t){if(this.isToggled(this.$el)&&this.draggable)switch(t.keyCode){case 37:this.show("previous");break;case 39:this.show("next")}}},{name:"beforeitemshow",handler:function(t){this.isToggled()||(this.draggable=!1,t.preventDefault(),this.toggleElement(this.$el,!0,!1),this.animation=Wr.scale,De(t.target,this.clsActive),this.stack.splice(1,0,this.index))}},{name:"itemshow",handler:function(){we(this.caption,this.getItem().caption||"");for(var t=-this.preload;t<=this.preload;t++)this.loadItem(this.index+t)}},{name:"itemshown",handler:function(){this.draggable=this.$props.draggable}},{name:"itemload",handler:function(t,n){var i=this,r=n.source,e=n.type,o=n.alt;void 0===o&&(o="");var s,a,u,c=n.poster,h=n.attrs;void 0===h&&(h={}),this.setItem(n,""),r&&(a={frameborder:"0",allow:"autoplay",allowfullscreen:"",style:"max-width: 100%; box-sizing: border-box;","uk-responsive":"","uk-video":""+this.videoAutoplay},"image"===e||r.match(/\.(avif|jpe?g|a?png|gif|svg|webp)($|\?)/i)?ge(r,h.srcset,h.size).then(function(t){var e=t.width,t=t.height;return i.setItem(n,Ur("img",Y({src:r,width:e,height:t,alt:o},h)))},function(){return i.setError(n)}):"video"===e||r.match(/\.(mp4|webm|ogv)($|\?)/i)?(Jt(u=Ur("video",Y({src:r,poster:c,controls:"",playsinline:"","uk-video":""+this.videoAutoplay},h)),"loadedmetadata",function(){ot(u,{width:u.videoWidth,height:u.videoHeight}),i.setItem(n,u)}),Jt(u,"error",function(){return i.setError(n)})):"iframe"===e||r.match(/\.(html|php)($|\?)/i)?this.setItem(n,Ur("iframe",Y({src:r,frameborder:"0",allowfullscreen:"",class:"uk-lightbox-iframe"},h))):(s=r.match(/\/\/(?:.*?youtube(-nocookie)?\..*?[?&]v=|youtu\.be\/)([\w-]{11})[&?]?(.*)?/))?this.setItem(n,Ur("iframe",Y({src:"https://www.youtube/"+(s[1]||"")+".com/embed/"+s[2]+(s[3]?"?"+s[3]:""),width:1920,height:1080},a,h))):(s=r.match(/\/\/.*?vimeo\.[a-z]+\/(\d+)[&?]?(.*)?/))&&me("https://vimeo.com/api/oembed.json?maxwidth=1920&url="+encodeURI(r),{responseType:"json",withCredentials:!1}).then(function(t){var e=t.response,t=e.height,e=e.width;return i.setItem(n,Ur("iframe",Y({src:"https://player.vimeo.com/video/"+s[1]+(s[2]?"?"+s[2]:""),width:e,height:t},a,h)))},function(){return i.setError(n)}))}}],methods:{loadItem:function(t){void 0===t&&(t=this.index);t=this.getItem(t);this.getSlide(t).childElementCount||te(this.$el,"itemload",[t])},getItem:function(t){return void 0===t&&(t=this.index),this.items[it(t,this.slides)]},setItem:function(t,e){te(this.$el,"itemloaded",[this,we(this.getSlide(t),e)])},getSlide:function(t){return this.slides[this.items.indexOf(t)]},setError:function(t){this.setItem(t,'')},showControls:function(){clearTimeout(this.controlsTimer),this.controlsTimer=setTimeout(this.hideControls,this.delayControls),Be(this.$el,"uk-active","uk-transition-active")},hideControls:function(){De(this.$el,"uk-active","uk-transition-active")}}};function Ur(t,e){t=_e("<"+t+">");return ot(t,e),t}Ki={install:function(t,e){t.lightboxPanel||t.component("lightboxPanel",qr);Y(e.props,t.component("lightboxPanel").options.props)},props:{toggle:String},data:{toggle:"a"},computed:{toggles:{get:function(t,e){return ze(t.toggle,e)},watch:function(){this.hide()}}},disconnected:function(){this.hide()},events:[{name:"click",delegate:function(){return this.toggle+":not(.uk-disabled)"},handler:function(t){t.preventDefault(),this.show(t.current)}}],methods:{show:function(t){var e,n=this,i=J(this.toggles.map(Yr),"source");return _(t)&&(e=Yr(t).source,t=x(i,function(t){t=t.source;return e===t})),this.panel=this.panel||this.$create("lightboxPanel",Y({},this.$props,{items:i})),Jt(this.panel.$el,"hidden",function(){return n.panel=!1}),this.panel.show(t)},hide:function(){return this.panel&&this.panel.hide()}}};function Yr(e){var n={};return["href","caption","type","poster","alt","attrs"].forEach(function(t){n["href"===t?"source":t]=ut(e,t)}),n.attrs=Tn(n.attrs),n}Gi={mixins:[$i],functional:!0,args:["message","status"],data:{message:"",status:"",timeout:5e3,group:null,pos:"top-center",clsContainer:"uk-notification",clsClose:"uk-notification-close",clsMsg:"uk-notification-message"},install:function(i){i.notification.closeAll=function(e,n){Ae(document.body,function(t){t=i.getComponent(t,"notification");!t||e&&e!==t.group||t.close(n)})}},computed:{marginProp:function(t){return"margin"+(g(t.pos,"top")?"Top":"Bottom")},startProps:function(){var t={opacity:0};return t[this.marginProp]=-this.$el.offsetHeight,t}},created:function(){var t=Me("."+this.clsContainer+"-"+this.pos,this.container)||be(this.container,'
        ');this.$mount(be(t,'
        '+this.message+"
        "))},connected:function(){var t,e=this,n=L(Re(this.$el,this.marginProp));Ze.start(Re(this.$el,this.startProps),((t={opacity:1})[this.marginProp]=n,t)).then(function(){e.timeout&&(e.timer=setTimeout(e.close,e.timeout))})},events:((Ji={click:function(t){Nt(t.target,'a[href="#"],a[href=""]')&&t.preventDefault(),this.close()}})[wt]=function(){this.timer&&clearTimeout(this.timer)},Ji[bt]=function(){this.timeout&&(this.timer=setTimeout(this.close,this.timeout))},Ji),methods:{close:function(t){function e(t){var e=Tt(t);te(t,"close",[n]),$e(t),e&&!e.hasChildNodes()&&$e(e)}var n=this;this.timer&&clearTimeout(this.timer),t?e(this.$el):Ze.start(this.$el,this.startProps).then(e)}}};var Xr=["x","y","bgx","bgy","rotate","scale","color","backgroundColor","borderColor","opacity","blur","hue","grayscale","invert","saturate","sepia","fopacity","stroke"],mr={mixins:[dr],props:Xr.reduce(function(t,e){return t[e]="list",t},{}),data:Xr.reduce(function(t,e){return t[e]=void 0,t},{}),computed:{props:function(f,p){var m=this;return Xr.reduce(function(t,e){if(O(f[e]))return t;var n,i,r=e.match(/color/i),o=r||"opacity"===e,s=f[e].slice();o&&Re(p,e,""),s.length<2&&s.unshift(("scale"===e?1:o?Re(p,e):0)||0);var a,u,c,h,l,o=s.reduce(function(t,e){return z(e)&&e.replace(/-|\d/g,"").trim()||t},"");if(r?(r=p.style.color,s=s.map(function(t){return Re(Re(p,"color",t),"color").split(/[(),]/g).slice(1,-1).concat(1).slice(0,4).map(L)}),p.style.color=r):g(e,"bg")?(a="bgy"===e?"height":"width",s=s.map(function(t){return pn(t,a,m.$el)}),Re(p,"background-position-"+e[2],""),i=Re(p,"backgroundPosition").split(" ")["x"===e[2]?0:1],n=m.covers?(u=Math.min.apply(Math,s),c=Math.max.apply(Math,s),h=s.indexOf(u)Qr(u||c))?"in":"out"),{dir:h,percent:i?1-r:n?r:e?1:0})})},percent:function(){return Math.abs((Re(l,"transform").split(",")[4]*(lt?-1:1)+e)/(d-e))},getDistance:function(){return Math.abs(d-e)},getItemIn:function(t){void 0===t&&(t=!1);var e=this.getActives(),n=to(l,Kr(c||u,l,i));return t&&(t=e,e=n,n=t),n[x(n,function(t){return!w(e,t)})]},getActives:function(){return to(l,Kr(u||c,l,i))}}}},computed:{avgWidth:function(){return Zr(this.list)/this.length},finite:function(t){return t.finite||Math.ceil(Zr(this.list))r.maxIndex?r.maxIndex:n)||(e=r.slides[n+1],r.center&&e&&in.maxIndex||n.sets&&!w(n.sets,e))}),!this.length||this.dragging||this.stack.length||(this.reorder(),this._translate(1));var e=this._getTransitioner(this.index).getActives();this.slides.forEach(function(t){return Le(t,n.clsActive,w(e,t))}),!this.clsActivated||this.sets&&!w(this.sets,L(this.index))||this.slides.forEach(function(t){return Le(t,n.clsActivated||"",w(e,t))})},events:["resize"]},events:{beforeitemshow:function(t){!this.dragging&&this.sets&&this.stack.length<2&&!w(this.sets,this.index)&&(this.index=this.getValidIndex());var e=Math.abs(this.index-this.prevIndex+(0this.prevIndex?(this.maxIndex+1)*this.dir:0));if(!this.dragging&&1=n.index?-1:"")}),this.center)for(var t=this.slides[i],e=on(this.list).width/2-on(t).width/2,r=0;0s[t]-i)&&e}}(i.target,r,n,e,a,i===s&&t.moved!==r))&&(a&&n===a||(i!==s?(s.remove(n),t.moved=r):delete t.moved,i.insert(n,a),this.touched.add(i)))))))},events:["move"]},methods:{init:function(t){var e=t.target,n=t.button,i=t.defaultPrevented,r=this.items.filter(function(t){return Bt(e,t)})[0];!r||i||0$)/g,"$1div$2"));return Re(t,"margin","0","important"),Re(t,Y({boxSizing:"border-box",width:e.offsetWidth,height:e.offsetHeight},Re(e,["paddingLeft","paddingRight","paddingTop","paddingBottom"]))),cn(t.firstElementChild,cn(e.firstElementChild)),t}(this.$container,this.placeholder);var e,n,i=this.placeholder.getBoundingClientRect(),r=i.left,i=i.top;Y(this.origin,{offsetLeft:this.pos.x-r,offsetTop:this.pos.y-i}),Be(this.drag,this.clsDrag,this.clsCustom),Be(this.placeholder,this.clsPlaceholder),Be(this.items,this.clsItem),Be(document.documentElement,this.clsDragState),te(this.$el,"start",[this,this.placeholder]),e=this.pos,n=Date.now(),ro=setInterval(function(){var t=e.x,s=e.y;s+=window.pageYOffset;var a=.3*(Date.now()-n);n=Date.now(),Vn(document.elementFromPoint(t,e.y)).reverse().some(function(t){var e=t.scrollTop,n=t.scrollHeight,i=sn(Rn(t)),r=i.top,o=i.bottom,i=i.height;if(rthis.threshold||Math.abs(this.pos.y-this.origin.y)>this.threshold)&&this.start(t)},end:function(){var t,i=this;Zt(document,gt,this.move),Zt(document,vt,this.end),Zt(window,"scroll",this.scroll),this.drag&&(clearInterval(ro),t=this.getSortable(this.placeholder),this===t?this.origin.index!==Pt(this.placeholder)&&te(this.$el,"moved",[this,this.placeholder]):(te(t.$el,"added",[t,this.placeholder]),te(this.$el,"removed",[this,this.placeholder])),te(this.$el,"stop",[this,this.placeholder]),$e(this.drag),this.drag=null,this.touched.forEach(function(t){var e=t.clsPlaceholder,n=t.clsItem;return i.touched.forEach(function(t){return De(t.items,e,n)})}),this.touched=null,De(document.documentElement,this.clsDragState))},insert:function(t,e){var n=this;Be(this.items,this.clsItem);this.animate(function(){return e?xe(e,t):be(n.target,t)})},remove:function(t){Bt(t,this.target)&&this.animate(function(){return $e(t)})},getSortable:function(t){do{var e=this.$getComponent(t,"sortable");if(e&&(e===this||!1!==this.group&&e.group===this.group))return e}while(t=Tt(t))}}};function oo(t,e){return t[1]>e[0]&&e[1]>t[0]}bt={mixins:[$i,mi,Si],args:"title",props:{delay:Number,title:String},data:{pos:"top",title:"",delay:0,animation:["uk-animation-scale-up"],duration:100,cls:"uk-active",clsPos:"uk-tooltip"},beforeConnect:function(){var t;this._hasTitle=st(this.$el,"title"),ot(this.$el,"title",""),this.updateAria(!1),Et(t=this.$el)||ot(t,"tabindex","0")},disconnected:function(){this.hide(),ot(this.$el,"title",this._hasTitle?this.title:null)},methods:{show:function(){var e=this;!this.isToggled(this.tooltip||null)&&this.title&&(this._unbind=Qt(document,"show keydown "+mt,this.hide,!1,function(t){return t.type===mt&&!Bt(t.target,e.$el)||"keydown"===t.type&&27===t.keyCode||"show"===t.type&&t.detail[0]!==e&&t.detail[0].$name===e.$name}),clearTimeout(this.showTimer),this.showTimer=setTimeout(this._show,this.delay))},hide:function(){var t=this;Mt(this.$el,"input:focus")||(clearTimeout(this.showTimer),this.isToggled(this.tooltip||null)&&this.toggleElement(this.tooltip,!1,!1).then(function(){t.tooltip=$e(t.tooltip),t._unbind()}))},_show:function(){var n=this;this.tooltip=be(this.container,'
        '+this.title+"
        "),Jt(this.tooltip,"toggled",function(t,e){n.updateAria(e),e&&(n.positionAt(n.tooltip,n.$el),n.origin="y"===n.getAxis()?fn(n.dir)+"-"+n.align:n.align+"-"+fn(n.dir))}),this.toggleElement(this.tooltip,!0)},updateAria:function(t){ot(this.$el,"aria-expanded",t)}},events:((Si={focus:"show",blur:"hide"})[wt+" "+bt]=function(t){ae(t)||this[t.type===wt?"show":"hide"]()},Si[mt]=function(t){ae(t)&&this.show()},Si)};Si={props:{allow:String,clsDragover:String,concurrent:Number,maxSize:Number,method:String,mime:String,msgInvalidMime:String,msgInvalidName:String,msgInvalidSize:String,multiple:Boolean,name:String,params:Object,type:String,url:String},data:{allow:!1,clsDragover:"uk-dragover",concurrent:1,maxSize:0,method:"POST",mime:!1,msgInvalidMime:"Invalid File Type: %s",msgInvalidName:"Invalid File Name: %s",msgInvalidSize:"Invalid File Size: %s Kilobytes Max",multiple:!1,name:"files[]",params:{},type:"",url:"",abort:Q,beforeAll:Q,beforeSend:Q,complete:Q,completeAll:Q,error:Q,fail:Q,load:Q,loadEnd:Q,loadStart:Q,progress:Q},events:{change:function(t){Mt(t.target,'input[type="file"]')&&(t.preventDefault(),t.target.files&&this.upload(t.target.files),t.target.value="")},drop:function(t){ao(t);t=t.dataTransfer;t&&t.files&&(De(this.$el,this.clsDragover),this.upload(t.files))},dragenter:function(t){ao(t)},dragover:function(t){ao(t),Be(this.$el,this.clsDragover)},dragleave:function(t){ao(t),De(this.$el,this.clsDragover)}},methods:{upload:function(t){var i=this;if(t.length){te(this.$el,"upload",[t]);for(var e=0;e}} + + 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/documentation/v2-migration.md b/docs/content/en/docs/migration/v2-migration.md similarity index 98% rename from docs/documentation/v2-migration.md rename to docs/content/en/docs/migration/v2-migration.md index 4500672308..5b0ef31c45 100644 --- a/docs/documentation/v2-migration.md +++ b/docs/content/en/docs/migration/v2-migration.md @@ -1,12 +1,9 @@ --- title: Migrating from v1 to v2 -description: Migrating from v1 to v2 layout: docs permalink: /docs/v2-migration --- -# Migrating from v1 to v2 - 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 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/documentation/architecture-and-internals.md b/docs/documentation/architecture-and-internals.md deleted file mode 100644 index 7163824a3d..0000000000 --- a/docs/documentation/architecture-and-internals.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Architecture and Internals -description: Architecture and Internals for Developers -layout: docs -permalink: /docs/architecture-and-internals ---- - -# Architecture and Internals - -This document gives an overview of the internal structure and components of Java Operator SDK core, in order to make it -easier for developers to understand and contribute to it. However, this is just an extract of the backbone of the core -module, but other parts should be fairly easy to understand. We will maintain this document on developer feedback. - -## The Big Picture and Core Components - -![Alt text for broken image link](../assets/images/architecture.svg) - -[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) -. Controller however, is an internal class managed by the framework itself. It encapsulates directly or indirectly all -the processing units for a single custom resource. Other components: - -- [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 the event sources regarding a controller. Provides starts and stops the event sources. -- [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 central event source that watches the controller related custom resource for changes, propagates events and - caches the state of the custom resources. In the background from V2 it uses Informers. -- [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 the incoming events. Implements execution serialization. Manages the executor service for execution. Also - implements the post-processing of after the reconciler was executed, like re-schedules and retries of events. -- [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) - is responsible for managing logic around reconciler execution, deciding which method should be called of the - reconciler, managing the result - (UpdateControl and DeleteControl), making the instructed Kubernetes API calls. -- [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) - is the primary entry-point for the developers of the framework to implement the reconciliation logic. - -## Typical Workflow - -A typical workflows looks like following: - -1. An EventSource produces and event, that is propagated to the event processor. -2. In the event processor the related `CustomResource` is read from the cache based on the `ResourceID` in the event. -3. If there is no other execution running for the custom resource, an execution is submitted for the executor (thread - pool) . -4. Executor call EventDispatcher what decides which method to execute of the reconciler. Let's say in this case it - was `reconcile(...)` -5. After reconciler execution the Dispatcher calls Kubernetes API server, since the `reconcile` method returned - with `UpdateControl.updateStatus(...)` result. -6. Now the dispatcher finishes the execution and calls back `EventProcessor` to finalize the execution. -7. EventProcessor checks if there is no `reschedule` or `retry` required and if there are no subsequent events received - for the custom resource -8. Neither of this happened, therefore the event execution finished. diff --git a/docs/documentation/contributing.md b/docs/documentation/contributing.md deleted file mode 100644 index 8e9f95fdcd..0000000000 --- a/docs/documentation/contributing.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: Contributing To Java Operator SDK -description: Contributing To Java Operator SDK -layout: docs -permalink: /docs/contributing ---- -# 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]({{baseurl}}/coc). 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/java-operator-sdk/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/java-operator-sdk/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* import [contributing/intellij-google-style.xml](contributing/intellij-google-style.xml) -- for *Eclipse* import [contributing/eclipse-google-style.xml](contributing/eclipse-google-style.xml) - -## 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/docs/documentation/faq.md b/docs/documentation/faq.md deleted file mode 100644 index da9de05aaa..0000000000 --- a/docs/documentation/faq.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: FAQ -description: Frequently asked questions -layout: docs -permalink: /docs/faq ---- - -### Q: How can I access the events which triggered the Reconciliation? -In the v1.* version events were exposed to `Reconciler` (in v1 called `ResourceController`). This -included events (Create, Update) of the custom resource, but also events produced by Event Sources. After -long discussions also with developers of golang version (controller-runtime), we decided to remove access to -these events. We already advocated to not use events in the reconciliation logic, since events can be lost. -Instead reconcile all the resources on every execution of reconciliation. On first this might sound a little -opinionated, but there was a sound agreement between the developers that this is the way to go. - -### Q: Can I re-schedule a reconciliation, possibly with a specific delay? -Yes, this can be done 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) -, see: - -```java - @Override - public UpdateControl reconcile( - EventSourceTestCustomResource resource, Context context) { - ... - return UpdateControl.updateStatus(resource).rescheduleAfter(10, TimeUnit.SECONDS); - } -``` - -without an update: - -```java - @Override - public UpdateControl reconcile( - EventSourceTestCustomResource resource, Context context) { - ... - return UpdateControl.noUpdate().rescheduleAfter(10, TimeUnit.SECONDS); - } -``` - -Although you might consider using `EventSources`, to handle reconciliation triggering in a smarter way. \ No newline at end of file diff --git a/docs/documentation/features.md b/docs/documentation/features.md deleted file mode 100644 index 95924e24dd..0000000000 --- a/docs/documentation/features.md +++ /dev/null @@ -1,484 +0,0 @@ ---- -title: Features -description: Features of the SDK -layout: docs -permalink: /docs/features ---- - -# Features - -Java Operator SDK is a high level framework and related tooling in order to facilitate implementation of Kubernetes -operators. The features are by default following the best practices in an opinionated way. However, feature flags and -other configuration options are provided to fine tune or turn off these features. - -## Reconciliation Execution in a Nutshell - -Reconciliation execution is always triggered by an event. Events typically come from the custom resource -(i.e. custom resource is created, updated or deleted) that the controller is watching, but also from different sources -(see event sources). When an event is received reconciliation is executed, unless there is already a reconciliation -happening for a particular custom resource. In other words it is guaranteed by the framework that no concurrent -reconciliation happens for a custom resource. - -After a reconciliation ( -i.e. [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) -called, a post-processing phase follows, where typically framework checks if: - -- an exception was thrown during execution, if yes schedules a retry. -- there are new events received during the controller execution, if yes schedule the execution again. -- there is an instruction to re-schedule the execution for the future, if yes schedules a timer event with the specified - delay. -- if none above, the reconciliation is finished. - -Briefly, in the hearth of the execution is an eventing system, where events are the triggers of the reconciliation -execution. - -## Finalizer Support - -[Kubernetes finalizers](https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers/) -make sure that a reconciliation happens when a custom resource is instructed to be deleted. Typical case when it's -useful, when an operator is down (pod not running). Without a finalizer the reconciliation - thus the cleanup - -i.e. [`Reconciler.cleanup(...)`](https://github.com/java-operator-sdk/java-operator-sdk/blob/b91221bb54af19761a617bf18eef381e8ceb3b4c/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Reconciler.java#L31) -would not happen if a custom resource is deleted. - -Finalizers are automatically added by the framework as the first step, thus after a custom 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 be present. The subsequent event will be received, which will trigger the first reconciliation. - -The finalizer that is automatically added will be also removed after the `cleanup` is executed on the reconciler. -However, the removal behaviour can be further customized, and can be instructed to "not remove yet" - this is useful just -in some specific corner cases, when there would be a long waiting period for some dependent resource cleanup. - -The name of the finalizers can be specified, in case it is not, a name will be generated. - -Automatic finalizer handling can be turned off, so when configured no finalizer will be added or removed. -See [`@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 for more details. - -### When not to Use Finalizers? - -Typically, automated finalizer handling should be turned off, in case the cleanup of **all** the dependent resources is -handled by Kubernetes itself. This is handled by -Kubernetes [garbage collection](https://kubernetes.io/docs/concepts/architecture/garbage-collection/#owners-dependents). -Setting the owner reference and related fields are not in the scope of the SDK, it's up to the user to have them set -properly when creating the objects. - -When automatic finalizer handling is turned off, the `Reconciler.cleanup(...)` method is not called at all. Not even in -case when a delete event received. So it does not make sense to implement this method and turn off finalizer at the same -time. - -## The `reconcile` and `cleanup` Methods of [`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 lifecycle of a custom resource can be clearly separated into two phases from the perspective of an operator. When a -custom resource is created or update, or on the other hand when the custom resource is deleted - or rather marked for -deletion in case a finalizer is used. - -This separation-related logic is automatically handled by the framework. The framework will always call `reconcile` -method, unless the custom resource is -[marked from deletion](https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers/#how-finalizers-work) -. From the point when the custom resource is marked from deletion, only the `cleanup` method is called. - -If there is **no finalizer** in place (see Finalizer Support section), the `cleanup` method is **not called**. - -### 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) - -These two classes are used to control the outcome or the desired behaviour after the reconciliation. - -The `UpdateControl` can instruct the framework to update the status sub-resource of the resource and/or re-schedule a -reconciliation with a desired time delay. - -```java - @Override - public UpdateControl reconcile( - EventSourceTestCustomResource resource, Context context) { - ... - return UpdateControl.updateStatus(resource).rescheduleAfter(10, TimeUnit.SECONDS); - } -``` - -without an update: - -```java - @Override - public UpdateControl reconcile( - EventSourceTestCustomResource resource, Context context) { - ... - return UpdateControl.noUpdate().rescheduleAfter(10, TimeUnit.SECONDS); - } -``` - -Note, that it's not always desirable to always schedule a retry, rather to use `EventSources` to trigger the -reconciliation. - -Those are the typical use cases of resource updates, however in some cases there it can happen that the controller wants -to update the custom resource itself (like adding annotations) or not to do any updates, which is also supported. - -It is also possible to update both the status and the custom resource with the `updateCustomResourceAndStatus` method. In -this case first the custom resource is updated then the status in two separate requests to K8S API. - -Always update the custom resource with `UpdateControl`, not with the actual kubernetes client if possible. - -On resource updates there is always an optimistic version control in place, to make sure that another update is not -overwritten (by setting `resourceVersion` ) . - -The `DeleteControl` 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) { - ... - return DeleteControl.defaultDelete(); -} - -``` - -However, there is a possibility to not remove the finalizer, this allows to clean up the resources in a more async way, -mostly for the 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 a deleted resource. - -## Automatic Observed Generation Handling - -Having `.observedGeneration` value on the status of the resource is a best practice to indicate the last generation of -the resource reconciled successfully by the controller. This helps the users / administrators to check if the custom -resource was reconciled. - -In order to have this feature working: - -- the **status class** (not the resource) must implement the - [`ObservedGenerationAware`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/ObservedGenerationAware.java) - interface. See also - the [`ObservedGenerationAwareStatus`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/ObservedGenerationAwareStatus.java) - which can also be extended. -- The other condition is that the `CustomResource.getStatus()` method should not return `null` - , but an instance of the class representing `status`. The best way to achieve this is to - override [`CustomResource.initStatus()`](https://github.com/fabric8io/kubernetes-client/blob/865e0ddf67b99f954aa55ab14e5806d53ae149ec/kubernetes-client/src/main/java/io/fabric8/kubernetes/client/CustomResource.java#L139) - . - -If these conditions are fulfilled and generation awareness not turned off, the observed generation is automatically set -by the framework after the `reconcile` method is called. Note that the observed generation is updated also -when `UpdateControl.noUpdate()` is returned from the reconciler. See this feature working in -the [WebPage example](https://github.com/java-operator-sdk/java-operator-sdk/blob/b91221bb54af19761a617bf18eef381e8ceb3b4c/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageStatus.java#L5) -. - -```java -public class WebPageStatus extends ObservedGenerationAwareStatus { - - private String htmlConfigMap; - - ... -} -``` - -Initializing status on custom resource: - -```java -@Group("sample.javaoperatorsdk") -@Version("v1") -public class WebPage extends CustomResource - implements Namespaced { - - @Override - protected WebPageStatus initStatus() { - return new WebPageStatus(); - } -} -``` - -## Generation Awareness and Event Filtering - -On an operator startup, the best practice is to reconcile all the resources. Since while operator was down, changes -might have made both to custom resource and dependent resources. - -When the first reconciliation is done successfully, the next reconciliation is triggered if either the dependent -resources are changed or the custom resource `.spec` is changed. If other fields like `.metadata` is changed on the -custom resource, the reconciliation could be skipped. This is supported out of the box, thus the reconciliation by -default is not triggered if the change to the main custom resource does not increase the `.metadata.generation` field. -Note that the increase of `.metada.generation` is handled automatically by Kubernetes. - -To turn on this feature set `generationAwareEventProcessing` to `false` for the `Reconciler`. - -## Support for Well Known (non-custom) Kubernetes Resources - -A Controller can be registered for a non-custom resource, so well known Kubernetes resources like ( -Ingress,Deployment,...). Note that automatic observed generation handling is not supported for these resources. Although -in case adding a secondary controller for well known k8s resource, probably the observed generation should be handled by -the primary controller. - -See -the [integration test](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/deployment/DeploymentReconciler.java) -for reconciling deployments. - -```java -public class DeploymentReconciler - implements Reconciler, TestExecutionInfoProvider { - - @Override - public UpdateControl reconcile( - Deployment resource, Context context) { - ... - } -``` - -## Max Interval Between Reconciliations - -In case informers are all in place and reconciler is implemented correctly, there is no need for additional triggers. -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 the resources are reconciled after certain time. This functionality is in place by default, there -is quite high interval (currently 10 hours) while the reconciliation is triggered. See how to override this using -the standard annotation: - -```java -@ControllerConfiguration(finalizerName = NO_FINALIZER, - reconciliationMaxInterval = @ReconciliationMaxInterval( - interval = 50, - timeUnit = TimeUnit.MILLISECONDS)) -``` - -The event is not propagated in a fixed rate, rather it's scheduled after each reconciliation. So the -next reconciliation will after at most within the specified interval after last reconciliation. - -This feature can be turned off by setting `reconciliationMaxInterval` to [`Constants.NO_RECONCILIATION_MAX_INTERVAL`](https://github.com/java-operator-sdk/java-operator-sdk/blob/442e7d8718e992a36880e42bd0a5c01affaec9df/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java#L8-L8) -or any non-positive number. - -The automatic retries are not affected by this feature, in case of an error no schedule is set by this feature. - -## Automatic Retries on Error - -When an exception is thrown from a controller, the framework will schedule an automatic retry of the reconciliation. The -retry is behavior is configurable, an implementation is provided that should cover most of the use-cases, see -[GenericRetry](https://github.com/java-operator-sdk/java-operator-sdk/blob/master/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java) -. But it is possible to provide a custom implementation. - -It is possible to set a limit on the number of retries. In -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 information is provided about the retry, particularly interesting is the `isLastAttempt`, since a different -behavior could be implemented based on this flag. Like setting an error message in the status in case of a last attempt; - -```java - GenericRetry.defaultLimitedExponentialRetry() - .setInitialInterval(5000) - .setIntervalMultiplier(1.5D) - .setMaxAttempts(5); -``` - -Event if the retry reached a limit, in case of a new event is received the reconciliation would happen again, it's just -won't be a result of a retry, but the new event. However, in case of an error happens also in this case, it won't -schedule a retry is at this point the retry limit is already reached. - -A successful execution resets the retry. - -### Setting Error Status After Last Retry Attempt - -In order to facilitate error reporting Reconciler can implement the following -[interface](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ErrorStatusHandler.java): - -```java -public interface ErrorStatusHandler { - - Optional updateErrorStatus(T resource, RetryInfo retryInfo, RuntimeException e); - -} -``` - -The `updateErrorStatus` method is called in case an exception is thrown from the reconciler. It is also called when -there is no retry configured, just after the reconciler execution. In the first call the `RetryInfo.getAttemptCount()` -is always zero, since it is not a result of a retry -(regardless if retry is configured or not). - -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. - -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. - -### Correctness and Automatic Retries - -There is a possibility to turn off the automatic retries. This is not desirable, unless there is a very specific reason. -Errors naturally happen, typically network errors can cause some temporal issues, another case is when a custom resource -is updated during the reconciliation (using `kubectl` for example), in this case if an update of the custom resource -from the controller (using `UpdateControl`) would fail on a conflict. The automatic retries covers these cases and will -result in a reconciliation, even if normally an event would not be processed as a result of a custom resource update -from previous example (like if there is no generation update as a result of the change and generation filtering is -turned on) - -## Retry and Rescheduling and Event Handling Common Behavior - -Retry, reschedule and standard event processing forms a relatively complex system, where these functionalities are not -independent of each other. In the following we describe the behavior in this section, so it is easier to understand the -intersections: - -1. A successful execution resets a retry and the rescheduled executions which were present before the reconciliation. - However, a new rescheduling can be instructed from the reconciliation outcome (`UpdateControl` or `DeleteControl`). -2. In case an exception happened, and a retry is initiated, but an event received meanwhile, then reconciliation will be - executed 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, will be still marked as the last attempt in the retry info. The point - (1) still holds, but in case of an error, no retry will happen. - -## 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 of dependent resources. To solve the mentioned problems above, de-facto we watch resources we manage -for changes, and reconcile the state if a resource is changed. Note that resources we are watching can be Kubernetes and -also non-Kubernetes objects. Typically, in case of 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. What happens is when we -create a dependent resource we also register an Event Source that will propagate events regarding the changes of that -resource. This way we avoid the need of polling, and can implement controllers very efficiently. - -![Alt text for broken image link](../assets/images/event-sources.png) - -There are few interesting points here: -The CustomResourceEvenSource event source is a special one, which sends events regarding changes of our custom resource, -this is an event source which is always registered for every controller by default. An event is always related to a -custom resource. Concurrency is still handled for you, thus we still guarantee that there is no concurrent execution of -the controller for the same custom resource ( -there is parallel execution if an event is related to another custom resource instance). - -### Caching and Event Sources - -Typically, when we work with Kubernetes (but possibly with others), we manage the objects in a declarative way. This is -true also for Event Sources. For example if we watch for changes of a Kubernetes Deployment object in the -InformerEventSource, we always receive the whole object from the Kubernetes API. Later when we try to reconcile in the -controller (not using events) we would like to check the state of this deployment (but also other dependent resources), -we could read the object again from Kubernetes API. However since we watch for the changes, we know that we always -receive the most up-to-date version in the Event Source. So naturally, what we can do is cache the latest received -objects (in the Event Source) and read it from there if needed. This is the preferred way, since it reduces the number -of requests to Kubernetes API server, and leads to faster reconciliation cycles. - -Note that when an operator starts and the first reconciliation is executed the caches are already populated for example -for `InformerEventSource`. Currently, this is not true however for `PerResourceEventSource`, where the cache might or -might not be populated. To handle this situation elegantly methods are provided which checks the object in cache, if -not found tries to get it from the supplier. See related [method](https://github.com/java-operator-sdk/java-operator-sdk/blob/e7fd79968a238d7e0acc446d949b83a06cea17b5/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/polling/PerResourcePollingEventSource.java#L145) -. - -### Registering Event Sources - -To register event sources `Reconciler` has to -implement [`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) -interface and init a list of event sources to register. The easiest way to see it is -on [tomcat example](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatReconciler.java) -(irrelevant details omitted): - -```java - -@ControllerConfiguration -public class TomcatReconciler implements Reconciler, EventSourceInitializer { - - @Override - public List prepareEventSources(EventSourceContext context) { - SharedIndexInformer deploymentInformer = - kubernetesClient.apps() - .deployments() - .inAnyNamespace() - .withLabel("app.kubernetes.io/managed-by", "tomcat-operator") - .runnableInformer(0); - - return List.of( - new InformerEventSource<>(deploymentInformer, d -> { - var ownerReferences = d.getMetadata().getOwnerReferences(); - if (!ownerReferences.isEmpty()) { - return Set.of(new ResourceID(ownerReferences.get(0).getName(), d.getMetadata().getNamespace())); - } else { - return EMPTY_SET; - } - })); - } - ... -} -``` - -In the example above an `InformerEventSource` is registered (more on this specific eventsource later). Multiple things -are going on here: - -1. An `SharedIndexInformer` (class from fabric8 Kubernetes client) is created. This will watch and produce events for - `Deployments` in every namespace, but will filter them based on label. So `Deployments` which are not managed by - `tomcat-operator` (the label is not present on them) will not trigger a reconciliation. -2. In the next step - an [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 created, which wraps the `SharedIndexInformer`. In addition to that a mapping functions is provided, **this maps - the event of the watched resource (in this case `Deployment`) to the custom resources to reconcile**. Not that in - this case this is a simple task, since `Deployment` is already created with an owner reference. Therefore, - the `ResourceID` - what identifies the custom resource to reconcile is created from the owner reference. - -Note that a set of `ResourceID` is returned, this is usually just a set with one element. The possibility to specify -multiple values are there to cover some rare corner cases. If an irrelevant resource is observed, an empty set can -be returned to not reconcile any custom resource. - -### Built-in EventSources - -There are multiple event-sources provided out of the box, the following are some more central ones: - -1. [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 there to cover events for all Kubernetes resources. Provides also a cache to use during the reconciliation. - Basically no other event source required to watch Kubernetes resources. -2. [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 API, which don't support webhooks or other event notifications. It extends the abstract - [CachingEventSource](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/CachingEventSource.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. -3. [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` only it not polls a specific API separately per custom resource, but - periodically and independently of actually observed custom resources. -5. [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) - is used to handle incoming events from webhooks and messaging systems. -6. [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) - - an eventsource that is automatically registered to listen to the changes of the main - resource the operation manages, it also maintains a cache of those objects that can be accessed from the 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). - -## Contextual Info for Logging with MDC - -Logging is enhanced with additional contextual information using [MDC](http://www.slf4j.org/manual.html#mdc). This -following attributes are available in most parts of reconciliation logic and during the execution of the controller: - -| MDC Key | Value added from Custom 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). - -## Monitoring with Micrometer - -## Automatic generation of CRDs - -Note that this is feature of [Fabric8 Kubernetes Client](https://github.com/fabric8io/kubernetes-client) not the JOSDK. -But it's worth to mention here. - -To automatically generate CRD manifests from your annotated Custom Resource classes, you only need to add the following -dependencies to your project: - -```xml - - - io.fabric8 - crd-generator-apt - provided - -``` - -The CRD will be generated in `target/classes/META-INF/fabric8` (or in `target/test-classes/META-INF/fabric8`, if you use -the `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 2 files: - -- `mycrs.java-operator-sdk.io-v1.yml` -- `mycrs.java-operator-sdk.io-v1beta1.yml` - -**NOTE:** -> Quarkus users using the `quarkus-operator-sdk` extension do not need to add any extra dependency to get their CRD generated as this is handled by the extension itself. - - - - diff --git a/docs/documentation/getting-started.md b/docs/documentation/getting-started.md deleted file mode 100644 index 72a06df93c..0000000000 --- a/docs/documentation/getting-started.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -title: java-operator-sdk -description: Build Kubernetes Operators in Java without hassle -layout: docs -permalink: /docs/getting-started ---- - -# Java Operator SDK - Documentation - -## Introduction & Resources on Operators - -Operators are easy and simple way to manage resource on Kubernetes clusters but -also outside the cluster. The goal of this SDK is to allow writing operators in Java by -providing a nice API and handling common issues regarding the operators on framework level. - -For an introduction, what is an operator see this [blog post](https://blog.container-solutions.com/kubernetes-operators-explained). - -You can read about the common problems what is this operator framework is solving for you [here](https://blog.container-solutions.com/a-deep-dive-into-the-java-operator-sdk). - -## Getting Started - -The easiest way to get started with SDK is start [minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/) and -execute one of our [examples](https://github.com/java-operator-sdk/samples/tree/main/mysql-schema). -There is a dedicated page to describe how to [use samples](/docs/using-samples). - -Here are the main steps to develop the code and deploy the operator to a Kubernetes cluster. A more detailed and specific -version can be found under `samples/mysql-schema/README.md`. - -1. Setup kubectl to work with your Kubernetes cluster of choice. -1. Apply Custom Resource Definition -1. Compile the whole project (framework + samples) using `mvn install` in the root directory -1. Run the main class of the sample you picked and check out the sample's README to see what it does. -When run locally the framework will use your Kubernetes client configuration (in ~/.kube/config) to make the connection -to the cluster. This is why it was important to set up kubectl up front. -1. You can work in this local development mode to play with the code. -1. Build the Docker image and push it to the registry -1. Apply RBAC configuration -1. Apply deployment configuration -1. Verify if the operator is up and running. Don't run it locally anymore to avoid conflicts in processing events from -the cluster's API server. - - - diff --git a/docs/documentation/intro-operators.md b/docs/documentation/intro-operators.md deleted file mode 100644 index f69cb2895a..0000000000 --- a/docs/documentation/intro-operators.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Introduction to Operators -description: Introduction to Operators -layout: docs -permalink: /docs/intro-operators ---- - -# Introduction To Operators - -This page provides a selection of articles that gives an introduction to Kubernetes operators. - -## Operators in General - - - [Introduction of the concept of Kubernetes Operators](https://blog.container-solutions.com/kubernetes-operators-explained) - - [Operator pattern explained in Kubernetes documentation](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/) - - [An explanation why Java Operators makes sense](https://blog.container-solutions.com/cloud-native-java-infrastructure-automation-with-kubernetes-operators) - - [What are the problems an operator framework is solving](https://csviri.medium.com/deep-dive-building-a-kubernetes-operator-sdk-for-java-developers-5008218822cb) - diff --git a/docs/documentation/patterns-best-practices.md b/docs/documentation/patterns-best-practices.md deleted file mode 100644 index 20dafcf30d..0000000000 --- a/docs/documentation/patterns-best-practices.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: Patterns and Best Practices -description: Patterns and Best Practices Implementing a Controller -layout: docs -permalink: /docs/patterns-best-practices ---- - -# Patterns and Best Practices - -This document describes patterns and best practices, to build and run operators, and how to implement them in terms of -Java Operator SDK. - -See also best practices in [Operator SDK](https://sdk.operatorframework.io/docs/best-practices/best-practices/). - -## Implementing a Reconciler - -### Reconcile All The Resources All the Time - -The reconciliation can be triggered by events from multiple sources. It could be tempting to check the events and -reconcile just the related resource or subset of resources that the controller manages. However, this is **considered as -an anti-pattern** in operators. If triggered, all resources should be reconciled. Usually this means only comparing the -target state with the current state in the cache for most of the resource. The reason behind this is events not reliable -In general, this means events can be lost. In addition to that the operator can crash and while down will miss events. - -In addition to that such approach might even complicate implementation logic in the `Reconciler`, since parallel -execution of the reconciler is not allowed for the same custom resource, there can be multiple events received for the -same resource or dependent resource during an ongoing execution, ordering those events could be also challenging. - -Since there is a consensus regarding this in the industry, from v2 the events are not even accessible for -the `Reconciler`. - -### EventSources and Caching - -As mentioned above during a reconciliation best practice is to reconcile all the dependent resources managed by the -controller. This means that we want to compare a target state with the actual state of the cluster. Reading the actual -state of a resource from the Kubernetes API Server directly all the time would mean a significant load. Therefore, it's -a common practice to instead create a watch for the dependent resources and cache their latest state. This is done -following the Informer pattern. In Java Operator SDK, informer is wrapped into an EventSource, to integrate it with the -eventing system of the framework, resulting in `InformerEventSource`. - -A new event that triggers the reconciliation is propagated when the actual resource is already in cache. So in -reconciler what should be just done is to compare the target calculated state of a dependent resource of the actual -state from the cache of the event source. If it is changed or not in the cache it needs to be created, respectively -updated. - -### Idempotency - -Since all the resources are reconciled during an execution and an execution can be triggered quite often, also retries -of a reconciliation can happen naturally in operators, the implementation of a `Reconciler` -needs to be idempotent. Luckily, since operators are usually managing already declarative resources, this is trivial to -do in most cases. - -### Sync or Async Way of Resource Handling - -In an implementation of reconciliation there can be a point when reconciler needs to wait a non-insignificant amount of -time while a resource gets up and running. For example, reconciler would do some additional step only if a Pod is ready -to receive requests. This problem can be approached in two ways synchronously or asynchronously. - -The async way is just return from the reconciler, if there are informers properly in place for the target resource, -reconciliation will be triggered on change. During the reconciliation the pod can be read from the cache of the informer -and a check on it's state can be conducted again. The benefit of this approach is that it will free up the thread, so it -can be used to reconcile other resources. - -The sync way would be to periodically poll the cache of the informer for the pod's state, until the target state is -reached. This would block the thread until the state is reached, which in some cases could take quite long. - -## Why to Have Automated Retries? - -Automatic retries are in place by default, it can be fine-tuned, but in general it's not advised to turn of automatic -retries. One of the reasons is that issues like network error naturally happen and are usually solved by a retry. -Another typical situation is for example when a dependent resource or the custom resource is updated, during the update -usually there is optimistic version control in place. So if someone updated the resource during reconciliation, maybe -using `kubectl` or another process, the update would fail on a conflict. A retry solves this problem simply by executing -the reconciliation again. - -## Managing State - -When managing only kubernetes resources an explicit state is not necessary about the resources. The state can be -read/watched, also filtered using labels. Or just following some naming convention. However, when managing external -resources, there can be a situation for example when the created resource can only be addressed by an ID generated when -the resource was created. This ID needs to be stored, so on next reconciliation it could be used to addressing the -resource. One place where it could go is the status sub-resource. On the other hand by definition status should be just -the result of a reconciliation. Therefore, it's advised in general, to put such state into a separate resource usually a -Kubernetes Secret or ConfigMap or a dedicated CustomResource, where the structure can be also validated. - diff --git a/docs/documentation/use-samples.md b/docs/documentation/use-samples.md deleted file mode 100644 index 616a782181..0000000000 --- a/docs/documentation/use-samples.md +++ /dev/null @@ -1,245 +0,0 @@ ---- -title: Using sample Operators -description: How to use sample Operators -layout: docs -permalink: /docs/using-samples ---- - -# Sample Operators we Provide - -We have few simple Operators under -the [smoke-test-samples](https://github.com/java-operator-sdk/java-operator-sdk/tree/master/smoke-test-samples) directory. -These are used mainly to showcase some minimal operators, but also to do some sanity checks during development: - -* *pure-java*: 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. -* *spring-boot-plain*: Sample showing integration with Spring Boot. - -In addition to that, there are examples under [sample-operators](https://github.com/java-operator-sdk/java-operator-sdk/tree/master/sample-operators) -directory which are intended to show usage of different components in different scenarios, but mainly are more real world -examples: - -* *webpage*: Simple example creating an NGINX webserver from a Custom Resource containing HTML code. -* *mysql-schema*: Operator managing schemas in a MySQL database. Shows how to manage non Kubernetes resources. -* *tomcat*: Operator with two controllers, managing Tomcat instances and Webapps running in Tomcat. The intention - with this example to show how to manage multiple related custom resources and/or more controllers. - -# Implementing a Sample Operator - -Add [dependency](https://search.maven.org/search?q=a:operator-framework) to your project with Maven: - -```xml - - - io.javaoperatorsdk - operator-framework - {see https://search.maven.org/search?q=a:operator-framework for latest version} - -``` - -Or alternatively with Gradle, which also requires declaring the SDK as an annotation processor to generate the mappings -between controllers and custom resource classes: - -```groovy -dependencies { - implementation "io.javaoperatorsdk:operator-framework:${javaOperatorVersion}" - annotationProcessor "io.javaoperatorsdk:operator-framework:${javaOperatorVersion}" -} -``` - -Once you've added the dependency, define a main method initializing the Operator and registering a controller. - -```java -public class Runner { - - public static void main(String[] args) { - Operator operator = new Operator(DefaultConfigurationService.instance()); - operator.register(new WebServerController()); - operator.start(); - } -} -``` - -The Controller implements the business logic and describes all the classes needed to handle the CRD. - -```java - -@ControllerConfiguration -public class WebPageReconciler implements Reconciler { - - // Return the changed resource, so it gets updated. See javadoc for details. - @Override - public UpdateControl reconcile(CustomService resource, - Context context) { - // ... your logic ... - return UpdateControl.updateStatus(resource); - } -} -``` - -A sample custom resource POJO representation - -```java - -@Group("sample.javaoperatorsdk") -@Version("v1") -public class WebPage extends CustomResource implements - Namespaced { -} - -public class WebServerSpec { - - private String html; - - public String getHtml() { - return html; - } - - public void setHtml(String html) { - this.html = html; - } -} -``` - -### Deactivating CustomResource implementations validation - -The operator will, by default, query the deployed CRDs to check that the `CustomResource` -implementations match what is known to the cluster. This requires an additional query to the cluster and, sometimes, -elevated privileges for the operator to be able to read the CRDs from the cluster. This validation is mostly meant to -help users new to operator development get started and avoid common mistakes. Advanced users or production deployments -might want to skip this step. This is done by setting the `CHECK_CRD_ENV_KEY` environment variable to `false`. - -### Automatic generation of CRDs - -To automatically generate CRD manifests from your annotated Custom Resource classes, you only need to add the following -dependencies to your project (in the background an annotation processor is used), with Maven: - -```xml - - - io.fabric8 - crd-generator-apt - provided - -``` - -or with Gradle: - -```groovy -dependencies { - annotationProcessor 'io.fabric8:crd-generator-apt:' - ... -} -``` - -The CRD will be generated in `target/classes/META-INF/fabric8` (or in `target/test-classes/META-INF/fabric8`, if you use -the `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 2 files: - -- `mycrs.java-operator-sdk.io-v1.yml` -- `mycrs.java-operator-sdk.io-v1beta1.yml` - -**NOTE:** -> Quarkus users using the `quarkus-operator-sdk` extension do not need to add any extra dependency to get their CRD generated as this is handled by the extension itself. - -### Quarkus - -A [Quarkus](https://quarkus.io) extension is also provided to ease the development of Quarkus-based operators. - -Add [this dependency](https://search.maven.org/search?q=a:quarkus-operator-sdk) -to your project: - -```xml - - - io.quarkiverse.operatorsdk - quarkus-operator-sdk - {see https://search.maven.org/search?q=a:quarkus-operator-sdk for latest version} - - -``` - -Create an Application, Quarkus will automatically create and inject a `KubernetesClient` ( -or `OpenShiftClient`), `Operator`, `ConfigurationService` and `ResourceController` instances that your application can -use. Below, you can see the minimal code you need to write to get your operator and controllers up and running: - -```java - -@QuarkusMain -public class QuarkusOperator implements QuarkusApplication { - - @Inject - Operator operator; - - public static void main(String... args) { - Quarkus.run(QuarkusOperator.class, args); - } - - @Override - public int run(String... args) throws Exception { - operator.start(); - Quarkus.waitForExit(); - return 0; - } -} -``` - -### Spring Boot - -You can also let Spring Boot wire your application together and automatically register the controllers. - -Add [this dependency](https://search.maven.org/search?q=a:operator-framework-spring-boot-starter) to your project: - -```xml - - - io.javaoperatorsdk - operator-framework-spring-boot-starter - {see https://search.maven.org/search?q=a:operator-framework-spring-boot-starter for - latest version} - - -``` - -Create an Application - -```java - -@SpringBootApplication -public class Application { - - public static void main(String[] args) { - SpringApplication.run(Application.class, args); - } -} -``` - -#### Spring Boot test support - -Adding the following dependency would let you mock the operator for the tests where loading the spring container is -necessary, but it doesn't need real access to a Kubernetes cluster. - -```xml - - - io.javaoperatorsdk - operator-framework-spring-boot-starter-test - {see https://search.maven.org/search?q=a:operator-framework-spring-boot-starter for - latest version} - - -``` - -Mock the operator: - -```java - -@SpringBootTest -@EnableMockOperator -public class SpringBootStarterSampleApplicationTest { - - @Test - void contextLoads() { - } -} -``` diff --git a/docs/etc/v1_model.drawio.svg b/docs/etc/v1_model.drawio.svg deleted file mode 100644 index 162e573db2..0000000000 --- a/docs/etc/v1_model.drawio.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
        Operator
        Operator
        ConfiguredController
        ConfiguredController
        DefaultEventSourceManager
        DefaultEventSourceManager
        DefaultEventHandler
        DefaultEventHandler
        EventDispatcher
        EventDispatcher
        ResourceController
        ResourceController
        EventSource
        EventSource
        EventHandler
        EventHandler
        1
        1
        1..*
        1..*
        1
        1
        1..*
        1..*
        Viewer does not support full SVG 1.1
        \ No newline at end of file diff --git a/docs/etc/v2_model.drawio.svg b/docs/etc/v2_model.drawio.svg deleted file mode 100644 index 9670b7d747..0000000000 --- a/docs/etc/v2_model.drawio.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
        Operator
        Operator
        Controller
        Controller
        EventSourceManager
        EventSourceManager
        EventProcessor
        EventProcessor
        EventSource
        EventSource
        ReconcilationDispatcher
        ReconcilationDispatcher
        Reconciler
        Reconciler
        1
        1
        1..*
        1..*
        1..*
        1..*
        Label
        Label
        1
        1
        Viewer does not support full SVG 1.1
        \ No newline at end of file diff --git a/docs/event-design.xml b/docs/event-design.xml deleted file mode 100644 index d7484bc84a..0000000000 --- a/docs/event-design.xml +++ /dev/null @@ -1 +0,0 @@ -5Vtbc5s4GP01nuk+pMNVxo+1kzad3W46cXazfZRBtjUFxICc2Pn1K4yEkYQdQsCmu3kJfICAo3P03fDInkXbLylM1t9IgMKRZQTbkX09stjf2GL/csuusHieWRhWKQ4KU8Uwxy+IGw1u3eAAZdKJlJCQ4kQ2+iSOkU8lG0xT8iyftiShfNcErpBmmPsw1K2POKBr/haucbDfIrxaizubBj8SQXEyN2RrGJDnism+GdmzlBBabEXbGQpz8AQuxXWfjxwtHyxFMW1ywQ1AV8vbP+nfuyv/8XfDu7t7ebjiozzBcMNf+OYpH88y5v4aBZsQpfzp6U5AkpJNHKB8VGNkT5/XmKJ5Av386DMjAbOtaRSyPZNt6k8pbolSirYVE3/qL4hEiKY7doogkc0R5BSyAN9/PkyIOea2dXUyxFxAToJVOfYBJ7bBoXoDbNZR2K5xlkDKsBsebs744rg5Om5b5G8oJjEzcwwV2FAcfMqFzPb8EGYZ9mWk0BbTf3JQP7p870flyPWW473f2YmdmL1N5aJ890f12OGy/Z647uisZGST+ujEm4u1C6YrRF9nFgqkZUmf48ocujVTKGwpCiHFT/JiVjet/A7fCd7TeKvQQ1DIUZhRvDe/qrr4KAOVqyAfyJ4oAxXAaAPtWVa+dnvigePrXDFzlvENxmwxH4JsXQkqr0a14KyqHWvgtRfpgCU6aShRc1ASdRSJmqqymkrUVSRqqlrvWaIi4qvQ7HtKWIAFKTo4B+PDbJNREt0jPp/WTBz8TWclm6I/4IKFpBIZYYhXcc5UdhETvD3NxYlZzPeJH4hwEORjTFOU4Re42I+XkyvJX34Phzsdude1dDspIXUVKANXfpNRNTasWx2ucjlI09QNi+RlXpl5slxmqJdJn7y6Ll94NXZNWRV1MVSdulUVdrYYm3rI/pgHnBWJDAQ6BzSI2+uw83rDTo/b23syKdw0Wvkys79ws2m8ORmUMxsrKYvttnRmY08ZSA2O+nZmeqZzz5FUVQpChuF0wQJPsMq3PnzNvVIMQ92fnVvBYHgKdi+j4Hco0RmWwhxlStsqDBhKHALOrDA9pTNPUYEkKFZ4EIt6I/ML9jRjD0zF2QGGEYmDhzWOxSFxriMMn3FYDsUmd85vS1K6JivCBHxzsJYxZ4iWtCbiXBDKgtoKOc0qNUuivuZepETp4Gs6dy+gY+8igltTXrQ7Ybxj10dwb86PJqfH6Zvvehb+gCPmDCzjHiUp8VGW6c7kF3I24yP4Vn2NdVZf43Xga4Q0zbdJs65e8toy8A4fNe5Yz2fyZUqQp+WtTaXtKUuEVkPpW9t6GjwjjA8kHGL/BTTtv/SWA4tloPso8HRNsr3CrIYCM4elMFOpCQKrpcLURoJ7Zu9p6fnYFNPFxv+Z17U6rZwEMFvvz+1IfhNrrJT8ymbKa65RDcm7E+CF0rB6d9pfGUXQ5hdzjEApWjptY161AOCo+u9btj21noaXUYmF/1WmgUExzQUdOQiVsk0dBJt6uKucxjs0xz2aVs93Tzsu5XzTlr6yYRvFE3RLez3zuEcrnOXdKt1ZnWiJFaew3A7AKHdT8SJLSpoOslFmddYpMz7aliP3tUQY9k7SezIjRJ7Yf7fM1ns/d3HOgLwS0HEY00XkomT0dV8ynDVsse3u3MmwC3SNM3pBqYH4E9NwZc6UyeebM47yexihU7VQ1HPsYusx8hzHqzAX6tc4ozD2B6hZT22cTWpEC8qMpEoB1WN3J1u91N9atgP/SrBpviHINRDZqqxxzJaqnagN4DN3leyaSpwSXBmzFEGaF97fI9UlDsMZCUm6v9ZeLpeW7+87Syn5iSpHArAALuhG3K6jlhJcp0bedbU8tU3Y3SfBei3vGiUh2UXFutgH2gFE3rIWbeB7aLHsC21gNERbLWR3h7YeQUpo/5UE/xm0x5dH2/ofod20K9Af2np0X+mMHvl1wXugRmbgonEd1BMwtmFvi7ZnXhxqvZwuEXtOId1kJcO7JbiLvMCpQ92zFjboDXXgXhx1PQ6ewygJq2nELWbRCr9XFXH22lSGVYYvJjFSsOam5kWgunmUZ7qXian9XZNVk6D0F8N08KXAUWga5/oDyQXU7zhaNx8mx1LRjkvCnlLDFt+Ktizxst3DjzqL0w8/jbVv/gU= \ No newline at end of file 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/index.html b/docs/index.html deleted file mode 100644 index 3ec0499797..0000000000 --- a/docs/index.html +++ /dev/null @@ -1,149 +0,0 @@ ---- -title: java-operator-sdk -description: Build Kubernetes Operators in Java without hassle -layout: homepage ---- - -

        -
        -

        Sponsored by:

        -
        - CS logo - & - CS logo -
        -
        -
        - -
        -
        -

        - 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. -

        -
        -
        -

        Why build your own Operator?

        -
        -
        -
        -

        Infrastructure automation using the power and flexibility of Java

        -

        - see blog - post

        -
        -
        -
        -
        -

        Provisioning of complex applications

        -

        - avoid Helm chart hell

        -
        -
        -
        -
        -

        Integration with Cloud services

        -

        - e.g. Secret stores

        -
        -
        -
        -
        -

        Safer deployment of applications

        -

        - only expose cluster to users by Custom Resources

        -
        -
        -
        -
        - -
        -

        Features

        -
        -
        -
          -
        • Framework for handling Kubernetes API events
        • -
        • Mapping Custom Resources to Java classes
        • -
        • Retry action on failure
        • -
        • Smart event scheduling (only handle latest event for the same resource)
        • -
        -
        -
        -
          -
        • Avoid concurrency issues - related events are serialized, unrelated executed in parallel
        • -
        • Smooth integration with Quarkus and Spring Boot
        • -
        • Handling of events from non-Kubernetes resources
        • -
        -
        -
        -
        - - -
        -

        Roadmap

        - -
        -
          -
        • Comprehensive - documentation -
        • -
        -
          -
        • Integrate with - operator-sdk to generate project skeleton -
        • -
        -
          -
        • Testing of the - framework and all samples while running on a real cluster. -
        • -
        -
          -
        • Generate Java - classes from CRD definion (and/or the other way around) -
        • -
        -
        -
        - - - -
        -

        Contributing

        -
        -
        -
        -
        -

        We are a friendly team of Java and Kubernetes enthusiasts and welcome everyone - to contribute in any way to the framework!

        -

        - -

        -
        -
        -
        -
        -

        - Get in touch either on GitHub or our Discord server, we are always - happy to chat and help - you find the right issue to get started.
        - Feel free to stop by for questions, comments or just saying "Hi". -

        -

        -

        We have a code of conduct - which we strictly enforce, as well as issues - marked for new joiners. -

        -

        We are also supporting #HacktoberFest and have several issues marked as good - candidates to pick up during the event. -

        - -
        -
        -
        \ No newline at end of file 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/readme.md b/docs/readme.md deleted file mode 100644 index d8b9055d5f..0000000000 --- a/docs/readme.md +++ /dev/null @@ -1,58 +0,0 @@ -## Run website locally -To run website locally run first `bundle install`and then `bundle exec jekyll serve` - -## CSS library - -The website uses [UIkit](https://getuikit.com/) as the css library. -inside _sass folder you will find: -* `/theme` folder which contains personalised variables, mixins and components used -(unused components, that can be used in the future, are commented out for size benefits) -* `/uikit` folder which contains the library default SCSS files. - -## Navigation - -The navigation bars (main menu, footer) make use of the `/_data/navbar.yml` file, here you can write down the title and url where you want to page to be reached at. It will automatically be added to the website. -Please make sure the `url` here is the same as the `permalink` inside the page front matter. - -For example: - -`navbar.yml` - -```title: Docs -url: /docs/getting-started -``` -`docs/getting-started.md` - -```--- -title: java-operator-sdk -description: Build Kubernetes Operators in Java without hassle -layout: docs -permalink: /docs/getting-started ---- -``` - -In order to create a navigation dropdown follow the following example: -```- title: Docs - url: /docs/getting-started - dropdown: - - title: Getting started - url: /docs/docs - - title: Examples - url: /docs/examples/ -``` -The sidebar for the docs pages makes use of `/_data/sidebar.yml` in the same manner as explained previously for the navigation bars. - -## Page Layouts - -There are three page layouts: -* `homepage` this is very specific to the homepage and shouldn't be used for other pages -* `docs` this is specific to all the pages that are related to documentation files. -* `default` this can be reused for any other pages. Mention the title in the Front Matter and omit it in the content of your page. - -## Documetation pages - -All documentation files should be added to the `documentation/` folder and for the navigation to have `/docs/page-name` in the url. - - -## Github api - -The website uses the [jekyll-github-metadata](https://github.com/jekyll/github-metadata) plugin in order to display new releases automatically, -this can be use for other purposes if need arises \ No newline at end of file diff --git a/docs/releases.html b/docs/releases.html deleted file mode 100644 index bc4f52cbb7..0000000000 --- a/docs/releases.html +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: Latest releases -description: Releases -layout: default -permalink: /releases ---- - - -{%- assign releases = site.github.releases | slice: 1, 7 -%} - -
        - {%- for release in releases -%} -
        -
        -
        {{release.published_at | date: "%b %d, %Y"}}
        -
        -
        -

        {{release.tag_name}} {{release.label}} -

        -
        {{release.body | markdownify}}
        -
        -
        - {%- endfor -%} -

        More details and older releases are available on GitHub

        -
        \ No newline at end of file diff --git a/docs/assets/images/favicon.ico b/docs/static/favicons/favicon.ico similarity index 100% rename from docs/assets/images/favicon.ico rename to docs/static/favicons/favicon.ico diff --git a/docs/assets/images/architecture.svg b/docs/static/images/architecture.svg similarity index 100% rename from docs/assets/images/architecture.svg rename to docs/static/images/architecture.svg 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/assets/images/cs-logo.svg b/docs/static/images/cs-logo.svg similarity index 100% rename from docs/assets/images/cs-logo.svg rename to docs/static/images/cs-logo.svg 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/assets/images/logo.png b/docs/static/images/full_logo.png similarity index 100% rename from docs/assets/images/logo.png rename to docs/static/images/full_logo.png diff --git a/docs/assets/images/red-hat.webp b/docs/static/images/red-hat.webp similarity index 100% rename from docs/assets/images/red-hat.webp rename to docs/static/images/red-hat.webp diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml index a2308d86e9..c66a2d339f 100644 --- a/micrometer-support/pom.xml +++ b/micrometer-support/pom.xml @@ -1,22 +1,15 @@ - + + 4.0.0 - java-operator-sdk io.javaoperatorsdk - 2.1.2-SNAPSHOT + java-operator-sdk + 5.1.5-SNAPSHOT - 4.0.0 micrometer-support Operator SDK - Micrometer Support - - 11 - 11 - - io.micrometer @@ -26,6 +19,37 @@ 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 + - \ No newline at end of file + diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java index d08f1c7669..26f149249b 100644 --- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java @@ -1,101 +1,448 @@ package io.javaoperatorsdk.operator.monitoring.micrometer; -import java.util.Collections; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; +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); + } - public MicrometerMetrics(MeterRegistry 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 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("controller", name) + .tags(tags) .publishPercentiles(0.3, 0.5, 0.95) .publishPercentileHistogram() .register(registry); try { - final var result = timer.record(execution::execute); + 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", "controller", name, "type", successType) - .increment(); + 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", "controller", name, "exception", exception) + .counter(execName + FAILURE_SUFFIX, CONTROLLER, name, EXCEPTION, exception) .increment(); throw e; } } - public void receivedEvent(Event event) { - incrementCounter(event.getRelatedCustomResourceID(), "events.received", "event", - event.getClass().getSimpleName()); + @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 customResourceUid) { - incrementCounter(customResourceUid, "events.delete"); + public void cleanupDoneFor(ResourceID resourceID, Map metadata) { + incrementCounter(resourceID, EVENTS_DELETE, metadata); + + cleaner.removeMetersFor(resourceID); } - public void reconcileCustomResource(ResourceID resourceID, RetryInfo retryInfoNullable) { + @Override + public void reconcileCustomResource( + HasMetadata resource, RetryInfo retryInfoNullable, Map metadata) { Optional retryInfo = Optional.ofNullable(retryInfoNullable); - incrementCounter(resourceID, RECONCILIATIONS + "started", - RECONCILIATIONS + "retries.number", - "" + retryInfo.map(RetryInfo::getAttemptCount).orElse(0), - RECONCILIATIONS + "retries.last", - "" + retryInfo.map(RetryInfo::isLastAttempt).orElse(true)); + 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(ResourceID resourceID) { - incrementCounter(resourceID, RECONCILIATIONS + "success"); + public void finishedReconciliation(HasMetadata resource, Map metadata) { + incrementCounter(ResourceID.fromResource(resource), RECONCILIATIONS_SUCCESS, metadata); } - public void failedReconciliation(ResourceID resourceID, RuntimeException exception) { + @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, RECONCILIATIONS + "failed", "exception", - cause.getClass().getSimpleName()); + 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", Collections.emptyList(), map); + 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 void incrementCounter(ResourceID id, String counterName, String... additionalTags) { - var tags = List.of( - "name", id.getName(), - "name", id.getName(), "namespace", id.getNamespace().orElse(""), - "scope", id.getNamespace().isPresent() ? "namespace" : "cluster"); - if (additionalTags != null && additionalTags.length > 0) { - tags = new LinkedList<>(tags); + 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)); } - registry.counter(PREFIX + counterName, tags.toArray(new String[0])).increment(); + + 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/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 index 4262525b45..5b4281a1ec 100644 --- a/operator-framework-core/pom.xml +++ b/operator-framework-core/pom.xml @@ -1,77 +1,46 @@ - + 4.0.0 io.javaoperatorsdk java-operator-sdk - 2.1.2-SNAPSHOT + 5.1.5-SNAPSHOT ../pom.xml operator-framework-core + jar Operator SDK - Framework - Core Core framework for implementing Kubernetes operators - jar - - - - - org.apache.maven.plugins - maven-surefire-plugin - - - - io.github.git-commit-id - git-commit-id-maven-plugin - ${git-commit-id-maven-plugin.version} - - - get-the-git-infos - - revision - - initialize - - - - true - ${project.build.outputDirectory}/version.properties - - - ^git.build.(time|version)$ - ^git.commit.id.(abbrev|full)$ - git.branch - - full - - - - - - + + io.github.java-diff-utils + java-diff-utils + io.fabric8 - openshift-client + kubernetes-client + + + io.fabric8 + kubernetes-httpclient-okhttp + + - org.slf4j slf4j-api org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl test org.apache.logging.log4j log4j-core ${log4j.version} - test-jar test @@ -85,6 +54,11 @@ junit-jupiter-engine test + + org.junit.jupiter + junit-jupiter-params + test + org.mockito mockito-core @@ -106,4 +80,51 @@ 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 index d49c373e76..6ae222c1c3 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/CustomResourceUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/CustomResourceUtils.java @@ -1,12 +1,12 @@ package io.javaoperatorsdk.operator; -import java.util.Arrays; - import io.fabric8.kubernetes.api.model.Cluster; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinition; -public abstract class CustomResourceUtils { +public class CustomResourceUtils { + + private CustomResourceUtils() {} /** * Applies internal validations that may not be handled by the fabric8 client. @@ -16,7 +16,7 @@ public abstract class CustomResourceUtils { * @throws OperatorException when the Custom Resource has validation error */ public static void assertCustomResource(Class resClass, CustomResourceDefinition crd) { - var namespaced = Arrays.asList(resClass.getInterfaces()).contains(Namespaced.class); + var namespaced = Namespaced.class.isAssignableFrom(resClass); if (!namespaced && Namespaced.class.getSimpleName().equals(crd.getSpec().getScope())) { throw new OperatorException( 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/Operator.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java index 5f71ec2747..f65f6ae022 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java @@ -1,20 +1,21 @@ package io.javaoperatorsdk.operator; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +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.DefaultKubernetesClient; 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.ExecutorServiceManager; +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; @@ -22,42 +23,130 @@ @SuppressWarnings("rawtypes") public class Operator implements LifecycleAware { private static final Logger log = LoggerFactory.getLogger(Operator.class); - private final KubernetesClient kubernetesClient; - private final ConfigurationService configurationService; - private final ControllerManager controllers = new ControllerManager(); + 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) { - this(new DefaultKubernetesClient(), configurationService); + init(configurationService, false); } /** - * Note that Operator by default closes the client on stop, this can be changed using - * {@link ConfigurationService} + * Creates an Operator overriding the default configuration with the values provided by the + * specified {@link ConfigurationServiceOverrider}. * - * @param kubernetesClient client to use to all Kubernetes related operations - * @param configurationService provides configuration + * @param overrider a {@link ConfigurationServiceOverrider} consumer used to override the default + * {@link ConfigurationService} values */ - public Operator(KubernetesClient kubernetesClient, ConfigurationService configurationService) { - this.kubernetesClient = kubernetesClient; - this.configurationService = configurationService; + public Operator(Consumer overrider) { + init(initConfigurationService(null, overrider), false); } - /** Adds a shutdown hook that automatically calls {@link #stop()} ()} when the app shuts down. */ - public void installShutdownHook() { - Runtime.getRuntime().addShutdownHook(new Thread(this::stop)); + /** + * 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); + } } - public KubernetesClient getKubernetesClient() { - return kubernetesClient; + /** + * 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); } - public ConfigurationService getConfigurationService() { - return configurationService; + /** + * 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 List getControllers() { - return new ArrayList<>(controllers.controllers.values()); + public KubernetesClient getKubernetesClient() { + return configurationService.getKubernetesClient(); } /** @@ -65,34 +154,54 @@ public List getControllers() { * where there is no obvious entrypoint to the application which can trigger the injection process * and start the cluster monitoring processes. */ - public void start() { - controllers.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); - - ExecutorServiceManager.init(configurationService); - controllers.start(); + 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(); - controllers.stop(); - - ExecutorServiceManager.stop(); + configurationService.getExecutorServiceManager().stop(reconciliationTerminationTimeout); + leaderElectionManager.stop(); if (configurationService.closeClientOnStop()) { - kubernetesClient.close(); + getKubernetesClient().close(); } + + started = false; } /** @@ -100,98 +209,111 @@ public void stop() throws OperatorException { * 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 + * @param

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

        RegisteredController

        register(Reconciler

        reconciler) throws OperatorException { final var controllerConfiguration = configurationService.getConfigurationFor(reconciler); - register(reconciler, controllerConfiguration); + 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)}, + * 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 CustomResource} type associated with 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 void register(Reconciler reconciler, - ControllerConfiguration configuration) - throws OperatorException { + 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()); + "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, kubernetesClient); + final var controller = new Controller<>(reconciler, configuration, getKubernetesClient()); - controllers.add(controller); + controllerManager.add(controller); - final var watchedNS = configuration.watchAllNamespaces() ? "[all namespaces]" - : configuration.getEffectiveNamespaces(); + 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; } - static class ControllerManager implements LifecycleAware { - private final Map controllers = new HashMap<>(); - private boolean started = false; + /** + * 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 synchronized void shouldStart() { - if (started) { - return; - } - if (controllers.isEmpty()) { - throw new OperatorException("No Controller exists. Exiting!"); - } - } + public Optional getRegisteredController(String name) { + return controllerManager.get(name).map(RegisteredController.class::cast); + } - public synchronized void start() { - controllers.values().parallelStream().forEach(Controller::start); - started = true; - } + public Set getRegisteredControllers() { + return new HashSet<>(controllerManager.controllers()); + } - public synchronized void stop() { - if (!started) { - return; - } + public int getRegisteredControllersNumber() { + return controllerManager.size(); + } - this.controllers.values().parallelStream().forEach(closeable -> { - log.debug("closing {}", closeable); - closeable.stop(); - }); + public RuntimeInfo getRuntimeInfo() { + return new RuntimeInfo(this); + } - started = false; - } + boolean isStarted() { + return started; + } - public synchronized void add(Controller controller) { - final var configuration = controller.getConfiguration(); - final var resourceTypeName = ReconcilerUtils - .getResourceTypeNameWithVersion(configuration.getResourceClass()); - final var existing = controllers.get(resourceTypeName); - if (existing != null) { - throw new OperatorException("Cannot register controller '" + configuration.getName() - + "': another controller named '" + existing.getConfiguration().getName() - + "' is already registered for resource '" + resourceTypeName + "'"); - } - this.controllers.put(resourceTypeName, controller); - if (started) { - controller.start(); - } - } + 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 index b11ff0a61a..895d643fcb 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/OperatorException.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/OperatorException.java @@ -8,6 +8,10 @@ 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 index e3a6da1e5a..a2d3d72e5f 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java @@ -1,9 +1,22 @@ 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.ObjectMeta; +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; @@ -13,31 +26,18 @@ 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) { - // todo: use fabric8 method when 5.12 is released - // return HasMetadata.validateFinalizer(finalizer); - final var validator = new HasMetadata() { - - @Override - public ObjectMeta getMetadata() { - throw new UnsupportedOperationException(); - } - - @Override - public void setMetadata(ObjectMeta objectMeta) { - throw new UnsupportedOperationException(); - } - - @Override - public void setApiVersion(String s) { - throw new UnsupportedOperationException(); - } - }; - return Constants.NO_FINALIZER.equals(finalizer) || validator.isFinalizerValid(finalizer); + return HasMetadata.validateFinalizer(finalizer); } public static String getResourceTypeNameWithVersion(Class resourceClass) { @@ -45,11 +45,8 @@ public static String getResourceTypeNameWithVersion(Class return getResourceTypeName(resourceClass) + "/" + version; } - public static String getResourceTypeName( - Class resourceClass) { - final var group = HasMetadata.getGroup(resourceClass); - final var plural = HasMetadata.getPlural(resourceClass); - return (group == null || group.isEmpty()) ? plural : plural + "." + group; + public static String getResourceTypeName(Class resourceClass) { + return HasMetadata.getFullResourceName(resourceClass); } public static String getDefaultFinalizerName(Class resourceClass) { @@ -70,7 +67,7 @@ public static String getNameFor(Class reconcilerClass) { final var annotation = reconcilerClass.getAnnotation(ControllerConfiguration.class); if (annotation != null) { final var name = annotation.name(); - if (!Constants.EMPTY_STRING.equals(name)) { + if (!Constants.NO_VALUE_SET.equals(name)) { return name; } } @@ -78,6 +75,36 @@ public static String getNameFor(Class reconcilerClass) { 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()); } @@ -98,4 +125,138 @@ public static String getDefaultReconcilerName(String reconcilerClassName) { } 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/ObservedGenerationAware.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/ObservedGenerationAware.java deleted file mode 100644 index 43927b8136..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/ObservedGenerationAware.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.javaoperatorsdk.operator.api; - -import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.client.CustomResource; -import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; - -/** - * If the custom resource's status implements this interface, the observed generation will be - * automatically handled. The last observed generation will be updated on status. - *

        - * In order for this automatic handling to work the status object returned by - * {@link CustomResource#getStatus()} should not be null. - *

        - * The observed generation is updated even when {@link UpdateControl#noUpdate()} or - * {@link UpdateControl#updateResource(HasMetadata)} is called. Although those results call normally - * does not result in a status update, there will be a subsequent status update Kubernetes API call - * in this case. - * - * @see ObservedGenerationAwareStatus - */ -public interface ObservedGenerationAware { - - void setObservedGeneration(Long generation); - - Long getObservedGeneration(); - -} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/ObservedGenerationAwareStatus.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/ObservedGenerationAwareStatus.java deleted file mode 100644 index d2048c9513..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/ObservedGenerationAwareStatus.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.javaoperatorsdk.operator.api; - -/** - * A helper base class for status sub-resources classes to extend to support generate awareness. - */ -public class ObservedGenerationAwareStatus implements ObservedGenerationAware { - - private Long observedGeneration; - - @Override - public void setObservedGeneration(Long generation) { - this.observedGeneration = generation; - } - - @Override - public Long getObservedGeneration() { - return observedGeneration; - } -} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java index 48c6b73f2b..5abe6a7d03 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java @@ -6,16 +6,73 @@ 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; - public AbstractConfigurationService(Version version) { + 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) { @@ -26,6 +83,7 @@ protected void replace(ControllerConfiguration config put(config, false); } + @SuppressWarnings("unchecked") private void put( ControllerConfiguration config, boolean failIfExisting) { final var name = config.getName(); @@ -36,7 +94,6 @@ private void put( } } configurations.put(name, config); - config.setConfigurationService(this); } protected void throwExceptionOnNameCollision( @@ -50,6 +107,7 @@ protected void throwExceptionOnNameCollision( + newReconcilerClassName); } + @SuppressWarnings("unchecked") @Override public ControllerConfiguration getConfigurationFor( Reconciler reconciler) { @@ -62,8 +120,7 @@ public ControllerConfiguration getConfigurationFor( } protected void logMissingReconcilerWarning(String reconcilerKey, String reconcilersNameMessage) { - System.out - .println("Cannot find reconciler named '" + reconcilerKey + "'. " + reconcilersNameMessage); + log.warn("Cannot find reconciler named '{}'. {}", reconcilerKey, reconcilersNameMessage); } private String getReconcilersNameMessage() { @@ -76,10 +133,12 @@ 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(); } @@ -93,4 +152,27 @@ public Set getKnownReconcilerNames() { 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 index 894091ad2d..891f199dbe 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java @@ -1,23 +1,169 @@ 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) { - super(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) { - logger.warn("Configuration for reconciler '{}' was not found. {}", reconcilerKey, - reconcilersNameMessage); + if (!createIfNeeded()) { + logger.warn( + "Configuration for reconciler '{}' was not found. {}", + reconcilerKey, + reconcilersNameMessage); + } } + @SuppressWarnings("unused") public String getLoggerName() { return LOGGER_NAME; } @@ -25,4 +171,174 @@ public String getLoggerName() { 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 index 30b2cee0e9..08cccab6f7 100644 --- 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 @@ -5,5 +5,4 @@ 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 index 2e4679efd3..41134e64ac 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java @@ -1,33 +1,97 @@ 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 com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +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 { - Cloner DEFAULT_CLONER = new Cloner() { - private final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + Logger log = LoggerFactory.getLogger(ConfigurationService.class); - @Override - public HasMetadata clone(HasMetadata object) { - try { - return OBJECT_MAPPER.readValue(OBJECT_MAPPER.writeValueAsString(object), object.getClass()); - } catch (JsonProcessingException e) { - throw new IllegalStateException(e); - } + 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 @@ -35,18 +99,57 @@ public HasMetadata clone(HasMetadata object) { * @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 + * null} if no configuration exists for the reconciler */ ControllerConfiguration getConfigurationFor(Reconciler reconciler); /** - * Retrieves the Kubernetes client configuration + * 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)}. * - * @return the configuration of the Kubernetes client, defaulting to the provided - * auto-configuration + *

        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 Config getClientConfiguration() { - return Config.autoConfigure(null); + default KubernetesClient getKubernetesClient() { + return new KubernetesClientBuilder() + .withConfig( + new ConfigBuilder(Config.autoConfigure(null)) + .withMaxConcurrentRequests(DEFAULT_MAX_CONCURRENT_REQUEST) + .build()) + .withKubernetesSerialization(new KubernetesSerialization()) + .build(); } /** @@ -64,62 +167,358 @@ default Config getClientConfiguration() { 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. + * 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. + *

        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 true; + return false; } - int DEFAULT_RECONCILIATION_THREADS_NUMBER = 5; - /** - * Retrieves the maximum number of threads the operator can spin out to dispatch reconciliation - * requests to reconcilers + * The number of threads the operator can spin out to dispatch reconciliation requests to + * reconcilers with the default executors * - * @return the maximum number of concurrent reconciliation threads + * @return the number of concurrent reconciliation threads */ default int concurrentReconciliationThreads() { return DEFAULT_RECONCILIATION_THREADS_NUMBER; } /** - * Used to clone custom resources. - * - * @return the ObjectMapper to use + * 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 Cloner getResourceCloner() { - return DEFAULT_CLONER; + default int concurrentWorkflowExecutorThreads() { + return DEFAULT_WORKFLOW_EXECUTOR_THREAD_NUMBER; } - int DEFAULT_TERMINATION_TIMEOUT_SECONDS = 10; - /** - * Retrieves the number of seconds the SDK waits for reconciliation threads to terminate before - * shutting down. + * Override to provide a custom {@link Metrics} implementation * - * @return the number of seconds to wait before terminating reconciliation threads + * @return the {@link Metrics} implementation */ - default int getTerminationTimeoutSeconds() { - return DEFAULT_TERMINATION_TIMEOUT_SECONDS; - } - 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 index c9b60d2617..be86cbe312 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java @@ -1,38 +1,52 @@ 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.Config; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.Operator; import io.javaoperatorsdk.operator.api.monitoring.Metrics; -import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +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 Config clientConfig; - private boolean checkCR; - private int threadNumber; + private Boolean checkCR; + private Integer concurrentReconciliationThreads; + private Integer concurrentWorkflowExecutorThreads; private Cloner cloner; - private int timeoutSeconds; - private boolean closeClientOnStop; + 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; - public ConfigurationServiceOverrider( - ConfigurationService original) { - this.original = original; - this.clientConfig = original.getClientConfiguration(); - this.checkCR = original.checkCRDAndValidateLocalModel(); - this.threadNumber = original.concurrentReconciliationThreads(); - this.cloner = original.getResourceCloner(); - this.timeoutSeconds = original.getTerminationTimeoutSeconds(); - this.metrics = original.getMetrics(); - this.closeClientOnStop = original.closeClientOnStop(); - } + @SuppressWarnings("rawtypes") + private DependentResourceFactory dependentResourceFactory; - - public ConfigurationServiceOverrider withClientConfiguration(Config configuration) { - this.clientConfig = configuration; - return this; + ConfigurationServiceOverrider(ConfigurationService original) { + this.original = original; } public ConfigurationServiceOverrider checkingCRDAndValidateLocalModel(boolean check) { @@ -41,17 +55,24 @@ public ConfigurationServiceOverrider checkingCRDAndValidateLocalModel(boolean ch } public ConfigurationServiceOverrider withConcurrentReconciliationThreads(int threadNumber) { - this.threadNumber = threadNumber; + this.concurrentReconciliationThreads = threadNumber; return this; } - public ConfigurationServiceOverrider withResourceCloner(Cloner cloner) { - this.cloner = cloner; + public ConfigurationServiceOverrider withConcurrentWorkflowExecutorThreads(int threadNumber) { + this.concurrentWorkflowExecutorThreads = threadNumber; return this; } - public ConfigurationServiceOverrider withTerminationTimeoutSeconds(int timeoutSeconds) { - this.timeoutSeconds = timeoutSeconds; + @SuppressWarnings("rawtypes") + public ConfigurationServiceOverrider withDependentResourceFactory( + DependentResourceFactory dependentResourceFactory) { + this.dependentResourceFactory = dependentResourceFactory; + return this; + } + + public ConfigurationServiceOverrider withResourceCloner(Cloner cloner) { + this.cloner = cloner; return this; } @@ -65,65 +86,270 @@ public ConfigurationServiceOverrider withCloseClientOnStop(boolean 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 ConfigurationService() { + return new BaseConfigurationService(original.getVersion(), cloner, client) { @Override - public ControllerConfiguration getConfigurationFor( - Reconciler reconciler) { - ControllerConfiguration controllerConfiguration = - original.getConfigurationFor(reconciler); - controllerConfiguration.setConfigurationService(this); - return controllerConfiguration; + public Set getKnownReconcilerNames() { + return original.getKnownReconcilerNames(); + } + + private T overriddenValueOrDefault( + T value, Function defaultValue) { + return value != null ? value : defaultValue.apply(original); } @Override - public Set getKnownReconcilerNames() { - return original.getKnownReconcilerNames(); + public boolean checkCRDAndValidateLocalModel() { + return overriddenValueOrDefault( + checkCR, ConfigurationService::checkCRDAndValidateLocalModel); + } + + @SuppressWarnings("rawtypes") + @Override + public DependentResourceFactory dependentResourceFactory() { + return overriddenValueOrDefault( + dependentResourceFactory, ConfigurationService::dependentResourceFactory); } @Override - public Version getVersion() { - return original.getVersion(); + public int concurrentReconciliationThreads() { + return Utils.ensureValid( + overriddenValueOrDefault( + concurrentReconciliationThreads, + ConfigurationService::concurrentReconciliationThreads), + "maximum reconciliation threads", + 1, + original.concurrentReconciliationThreads()); } @Override - public Config getClientConfiguration() { - return clientConfig; + public int concurrentWorkflowExecutorThreads() { + return Utils.ensureValid( + overriddenValueOrDefault( + concurrentWorkflowExecutorThreads, + ConfigurationService::concurrentWorkflowExecutorThreads), + "maximum workflow execution threads", + 1, + original.concurrentWorkflowExecutorThreads()); } @Override - public boolean checkCRDAndValidateLocalModel() { - return checkCR; + public Metrics getMetrics() { + return overriddenValueOrDefault(metrics, ConfigurationService::getMetrics); } @Override - public int concurrentReconciliationThreads() { - return threadNumber; + public boolean closeClientOnStop() { + return overriddenValueOrDefault(closeClientOnStop, ConfigurationService::closeClientOnStop); } @Override - public Cloner getResourceCloner() { - return cloner; + public ExecutorService getExecutorService() { + if (executorService != null) { + return executorService; + } else { + return super.getExecutorService(); + } } @Override - public int getTerminationTimeoutSeconds() { - return timeoutSeconds; + public ExecutorService getWorkflowExecutorService() { + if (workflowExecutorService != null) { + return workflowExecutorService; + } else { + return super.getWorkflowExecutorService(); + } } @Override - public Metrics getMetrics() { - return metrics; + public Optional getLeaderElectionConfiguration() { + return leaderElectionConfiguration != null + ? Optional.of(leaderElectionConfiguration) + : original.getLeaderElectionConfiguration(); } @Override - public boolean closeClientOnStop() { - return closeClientOnStop; + public Optional getInformerStoppedHandler() { + return informerStoppedHandler != null + ? Optional.of(informerStoppedHandler) + : original.getInformerStoppedHandler(); + } + + @Override + public boolean stopOnInformerErrorDuringStartup() { + return overriddenValueOrDefault( + stopOnInformerErrorDuringStartup, + ConfigurationService::stopOnInformerErrorDuringStartup); } - }; - } - public static ConfigurationServiceOverrider override(ConfigurationService original) { - return new ConfigurationServiceOverrider(original); + @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 index eb247752aa..2c18fa55d3 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java @@ -1,123 +1,95 @@ package io.javaoperatorsdk.operator.api.config; -import java.lang.reflect.ParameterizedType; import java.time.Duration; -import java.util.Collections; 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.reconciler.Constants; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilters; +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 { +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 ReconcilerUtils.getDefaultReconcilerName(getAssociatedReconcilerClassName()); + return ensureValidName(null, getAssociatedReconcilerClassName()); } - default String getResourceTypeName() { - return ReconcilerUtils.getResourceTypeName(getResourceClass()); + default String getFinalizerName() { + return ReconcilerUtils.getDefaultFinalizerName(getResourceClass()); } - default String getFinalizer() { - return ReconcilerUtils.getDefaultFinalizerName(getResourceClass()); + static String ensureValidName(String name, String reconcilerClassName) { + return name != null ? name : ReconcilerUtils.getDefaultReconcilerName(reconcilerClassName); } - /** - * Retrieves the label selector that is used to filter which custom resources are actually watched - * by the associated controller. See - * https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ for more details on - * syntax. - * - * @return the label selector filtering watched custom resources - */ - default String getLabelSelector() { - return null; + 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; } - default Class getResourceClass() { - ParameterizedType type = (ParameterizedType) getClass().getGenericInterfaces()[0]; - return (Class) type.getActualTypeArguments()[0]; - } - String getAssociatedReconcilerClassName(); - default Set getNamespaces() { - return Collections.emptySet(); - } - - default boolean watchAllNamespaces() { - return allNamespacesWatched(getNamespaces()); + default Retry getRetry() { + return GenericRetry.DEFAULT; } - static boolean allNamespacesWatched(Set namespaces) { - return namespaces == null || namespaces.isEmpty(); + @SuppressWarnings("rawtypes") + default RateLimiter getRateLimiter() { + return DEFAULT_RATE_LIMITER; } - default boolean watchCurrentNamespace() { - return currentNamespaceWatched(getNamespaces()); + default Optional getWorkflowSpec() { + return Optional.empty(); } - static boolean currentNamespaceWatched(Set namespaces) { - return namespaces != null - && namespaces.size() == 1 - && namespaces.contains( - Constants.WATCH_CURRENT_NAMESPACE); - } - - /** - * 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 - */ - default Set getEffectiveNamespaces() { - var targetNamespaces = getNamespaces(); - if (watchCurrentNamespace()) { - final var parent = getConfigurationService(); - if (parent == null) { - throw new IllegalStateException( - "Parent ConfigurationService must be set before calling this method"); - } - targetNamespaces = Collections.singleton(parent.getClientConfiguration().getNamespace()); - } - return targetNamespaces; - } - - default RetryConfiguration getRetryConfiguration() { - return RetryConfiguration.DEFAULT; + default Optional maxReconciliationInterval() { + return Optional.of(Duration.ofHours(MaxReconciliationInterval.DEFAULT_INTERVAL)); } ConfigurationService getConfigurationService(); - default void setConfigurationService(ConfigurationService service) {} - - default boolean useFinalizer() { - return !Constants.NO_FINALIZER - .equals(getFinalizer()); + @SuppressWarnings("unused") + default Set getEffectiveNamespaces() { + return getInformerConfig().getEffectiveNamespaces(this); } /** - * Allow controllers to filter events before they are provided to the - * {@link io.javaoperatorsdk.operator.processing.event.EventHandler}. Note that the provided - * filter is combined with {@link #isGenerationAware()} to compute the final set of filters that - * should be applied; + * 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 filter + * @return the name used as field manager for SSA operations */ - default ResourceEventFilter getEventFilter() { - return ResourceEventFilters.passthrough(); + default String fieldManager() { + return getName(); } - default Optional reconciliationMaxInterval() { - return Optional.of(Duration.ofHours(10L)); - } + 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 index c018c369f0..d2e37a397d 100644 --- 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 @@ -1,34 +1,47 @@ 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.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter; - +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 final Set namespaces; - private RetryConfiguration retry; - private String labelSelector; - private ResourceEventFilter customResourcePredicate; - private final ControllerConfiguration original; + private Retry retry; + private RateLimiter rateLimiter; + private String fieldManager; private Duration reconciliationMaxInterval; + private Map configurations; + private final InformerConfiguration.Builder config; private ControllerConfigurationOverrider(ControllerConfiguration original) { - finalizer = original.getFinalizer(); - generationAware = original.isGenerationAware(); - namespaces = new HashSet<>(original.getNamespaces()); - retry = original.getRetryConfiguration(); - labelSelector = original.getLabelSelector(); - customResourcePredicate = original.getEventFilter(); - reconciliationMaxInterval = original.reconciliationMaxInterval().orElse(null); + 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) { @@ -41,40 +54,66 @@ public ControllerConfigurationOverrider withGenerationAware(boolean generatio return this; } - public ControllerConfigurationOverrider withCurrentNamespace() { - namespaces.clear(); + public ControllerConfigurationOverrider watchingOnlyCurrentNamespace() { + config.withWatchCurrentNamespace(); return this; } public ControllerConfigurationOverrider addingNamespaces(String... namespaces) { - this.namespaces.addAll(List.of(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) { - List.of(namespaces).forEach(this.namespaces::remove); + 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) { - this.namespaces.clear(); - this.namespaces.add(namespace); + config.withNamespaces(Set.of(namespace)); return this; } - public ControllerConfigurationOverrider withRetry(RetryConfiguration retry) { + public ControllerConfigurationOverrider watchingAllNamespaces() { + config.withWatchAllNamespaces(); + return this; + } + + public ControllerConfigurationOverrider withRetry(Retry retry) { this.retry = retry; return this; } - public ControllerConfigurationOverrider withLabelSelector(String labelSelector) { - this.labelSelector = labelSelector; + public ControllerConfigurationOverrider withRateLimiter(RateLimiter rateLimiter) { + this.rateLimiter = rateLimiter; return this; } - public ControllerConfigurationOverrider withCustomResourcePredicate( - ResourceEventFilter customResourcePredicate) { - this.customResourcePredicate = customResourcePredicate; + public ControllerConfigurationOverrider withLabelSelector(String labelSelector) { + config.withLabelSelector(labelSelector); return this; } @@ -84,20 +123,82 @@ public ControllerConfigurationOverrider withReconciliationMaxInterval( 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 DefaultControllerConfiguration<>( - original.getAssociatedReconcilerClassName(), - original.getName(), - original.getResourceTypeName(), - finalizer, + return new ResolvedControllerConfiguration<>( + name, generationAware, - namespaces, + original.getAssociatedReconcilerClassName(), retry, - labelSelector, - customResourcePredicate, - original.getResourceClass(), + rateLimiter, reconciliationMaxInterval, - original.getConfigurationService()); + finalizer, + configurations, + fieldManager, + original.getConfigurationService(), + config.buildForController(), + original.getWorkflowSpec().orElse(null)); } public static ControllerConfigurationOverrider override( diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultControllerConfiguration.java deleted file mode 100644 index 33ff56899e..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultControllerConfiguration.java +++ /dev/null @@ -1,135 +0,0 @@ -package io.javaoperatorsdk.operator.api.config; - -import java.time.Duration; -import java.util.Collections; -import java.util.Optional; -import java.util.Set; - -import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter; - -public class DefaultControllerConfiguration - implements ControllerConfiguration { - - private final String associatedControllerClassName; - private final String name; - private final String crdName; - private final String finalizer; - private final boolean generationAware; - private final Set namespaces; - private final boolean watchAllNamespaces; - private final RetryConfiguration retryConfiguration; - private final String labelSelector; - private final ResourceEventFilter resourceEventFilter; - private final Class resourceClass; - private final Duration reconciliationMaxInterval; - private ConfigurationService service; - - public DefaultControllerConfiguration( - String associatedControllerClassName, - String name, - String crdName, - String finalizer, - boolean generationAware, - Set namespaces, - RetryConfiguration retryConfiguration, - String labelSelector, - ResourceEventFilter resourceEventFilter, - Class resourceClass, - Duration reconciliationMaxInterval, - ConfigurationService service) { - this.associatedControllerClassName = associatedControllerClassName; - this.name = name; - this.crdName = crdName; - this.finalizer = finalizer; - this.generationAware = generationAware; - this.namespaces = - namespaces != null ? Collections.unmodifiableSet(namespaces) : Collections.emptySet(); - this.reconciliationMaxInterval = reconciliationMaxInterval; - this.watchAllNamespaces = this.namespaces.isEmpty(); - this.retryConfiguration = - retryConfiguration == null - ? ControllerConfiguration.super.getRetryConfiguration() - : retryConfiguration; - this.labelSelector = labelSelector; - this.resourceEventFilter = resourceEventFilter; - this.resourceClass = - resourceClass == null ? ControllerConfiguration.super.getResourceClass() - : resourceClass; - setConfigurationService(service); - } - - @Override - public String getName() { - return name; - } - - @Override - public String getResourceTypeName() { - return crdName; - } - - @Override - public String getFinalizer() { - return finalizer; - } - - @Override - public boolean isGenerationAware() { - return generationAware; - } - - @Override - public String getAssociatedReconcilerClassName() { - return associatedControllerClassName; - } - - @Override - public Set getNamespaces() { - return namespaces; - } - - @Override - public boolean watchAllNamespaces() { - return watchAllNamespaces; - } - - @Override - public RetryConfiguration getRetryConfiguration() { - return retryConfiguration; - } - - @Override - public ConfigurationService getConfigurationService() { - return service; - } - - @Override - public void setConfigurationService(ConfigurationService service) { - if (this.service != null) { - throw new RuntimeException("A ConfigurationService is already associated with '" + name - + "' ControllerConfiguration. Cannot change it once set!"); - } - this.service = service; - } - - @Override - public String getLabelSelector() { - return labelSelector; - } - - @Override - public Class getResourceClass() { - return resourceClass; - } - - @Override - public ResourceEventFilter getEventFilter() { - return resourceEventFilter; - } - - @Override - public Optional reconciliationMaxInterval() { - return Optional.ofNullable(reconciliationMaxInterval); - } -} 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/DefaultRetryConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultRetryConfiguration.java deleted file mode 100644 index 4e891b3fd3..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/DefaultRetryConfiguration.java +++ /dev/null @@ -1,4 +0,0 @@ -package io.javaoperatorsdk.operator.api.config; - -public class DefaultRetryConfiguration implements RetryConfiguration { -} 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 index dfe64d97d0..3cbf68d8fe 100644 --- 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 @@ -1,79 +1,159 @@ 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 static ExecutorServiceManager instance; - private final ExecutorService executor; - private final int terminationTimeoutSeconds; + 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); + } - private ExecutorServiceManager(InstrumentedExecutorService executor, - int terminationTimeoutSeconds) { - this.executor = executor; - this.terminationTimeoutSeconds = terminationTimeoutSeconds; + /** + * 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 init(ConfigurationService configuration) { - if (instance == null) { - if (configuration == null) { - configuration = new BaseConfigurationService(Version.UNKNOWN); - } - instance = new ExecutorServiceManager( - new InstrumentedExecutorService(configuration.getExecutorService()), - configuration.getTerminationTimeoutSeconds()); - log.debug("Initialized ExecutorServiceManager executor: {}, timeout: {}", - configuration.getExecutorService().getClass(), - configuration.getTerminationTimeoutSeconds()); - } else { - log.debug("Already started, reusing already setup instance!"); + 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 static void stop() { - if (instance != null) { - instance.doStop(); - } - // make sure that we remove the singleton so that the thread pool is re-created on next call to - // start - instance = null; + public ExecutorService reconcileExecutorService() { + return executor; + } + + public ExecutorService workflowExecutorService() { + lazyInitWorkflowExecutorService(); + return workflowExecutor; } - public static ExecutorServiceManager instance() { - if (instance == null) { - // provide a default configuration if none has been provided by init - init(null); + private synchronized void lazyInitWorkflowExecutorService() { + if (workflowExecutor == null) { + workflowExecutor = + new InstrumentedExecutorService(configurationService.getWorkflowExecutorService()); } - return instance; } - public ExecutorService executorService() { - return executor; + 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; + } } - private void doStop() { + public void stop(Duration gracefulShutdownTimeout) { try { log.debug("Closing executor"); - executor.shutdown(); - if (!executor.awaitTermination(terminationTimeoutSeconds, TimeUnit.SECONDS)) { - executor.shutdownNow(); // if we timed out, waiting, cancel everything - } + 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; @@ -136,8 +216,9 @@ public List> invokeAll(Collection> tasks) } @Override - public List> invokeAll(Collection> tasks, long timeout, - TimeUnit unit) throws InterruptedException { + public List> invokeAll( + Collection> tasks, long timeout, TimeUnit unit) + throws InterruptedException { return executor.invokeAll(tasks, timeout, unit); } 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/RetryConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/RetryConfiguration.java deleted file mode 100644 index dee54c3e8d..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/RetryConfiguration.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.javaoperatorsdk.operator.api.config; - -public interface RetryConfiguration { - - RetryConfiguration DEFAULT = new DefaultRetryConfiguration(); - - int DEFAULT_MAX_ATTEMPTS = 5; - long DEFAULT_INITIAL_INTERVAL = 2000L; - double DEFAULT_MULTIPLIER = 1.5D; - - default int getMaxAttempts() { - return DEFAULT_MAX_ATTEMPTS; - } - - default long getInitialInterval() { - return DEFAULT_INITIAL_INTERVAL; - } - - default double getIntervalMultiplier() { - return DEFAULT_MULTIPLIER; - } - - default long getMaxInterval() { - return (long) (DEFAULT_INITIAL_INTERVAL * Math.pow(DEFAULT_MULTIPLIER, DEFAULT_MAX_ATTEMPTS)); - } -} 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 index b36c0468cd..3b6f94a025 100644 --- 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 @@ -1,19 +1,33 @@ package io.javaoperatorsdk.operator.api.config; import java.io.IOException; -import java.text.SimpleDateFormat; +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 @@ -21,7 +35,7 @@ public class Utils { * * @return a {@link Version} object encapsulating the version information */ - public static Version loadFromProperties() { + private static Version loadFromProperties() { final var is = Thread.currentThread().getContextClassLoader().getResourceAsStream("version.properties"); @@ -40,9 +54,7 @@ public static Version loadFromProperties() { try { String time = properties.getProperty("git.build.time"); if (time != null) { - builtTime = - // RFC 822 date is the default format used by git-commit-id-plugin - new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(time); + builtTime = Date.from(Instant.parse(time)); } else { builtTime = Date.from(Instant.EPOCH); } @@ -50,21 +62,293 @@ public static Version loadFromProperties() { log.debug("Couldn't parse git.build.time property", e); builtTime = Date.from(Instant.EPOCH); } - return new Version( - properties.getProperty("git.build.version", "unknown"), - properties.getProperty("git.commit.id.abbrev", "unknown"), - builtTime); + 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 Boolean.getBoolean(System.getProperty(CHECK_CRD_ENV_KEY, "true")); + return getBooleanFromSystemPropsOrDefault(CHECK_CRD_ENV_KEY, false); } public static boolean debugThreadPool() { - return Boolean.getBoolean(System.getProperty(DEBUG_THREAD_POOL_ENV_KEY, "false")); + 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 index 6bfb5bb2e5..571e389ecc 100644 --- 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 @@ -6,14 +6,11 @@ /** A class encapsulating the version information associated with this SDK instance. */ public class Version { - public static final Version UNKNOWN = new Version("unknown", "unknown", Date.from(Instant.EPOCH)); - - private final String sdk; + public static final Version UNKNOWN = new Version("unknown", Date.from(Instant.EPOCH)); private final String commit; private final Date builtTime; - public Version(String sdkVersion, String commit, Date builtTime) { - this.sdk = sdkVersion; + public Version(String commit, Date builtTime) { this.commit = commit; this.builtTime = builtTime; } @@ -24,7 +21,7 @@ public Version(String sdkVersion, String commit, Date builtTime) { * @return the SDK project version */ public String getSdkVersion() { - return sdk; + return Versions.JOSDK; } /** @@ -39,10 +36,20 @@ public String getCommit() { /** * 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 + * @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 index d7daba49ae..3e3c834c3e 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java @@ -2,38 +2,174 @@ 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() {}; - default void receivedEvent(Event event) {} + /** + * 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) {} - default void reconcileCustomResource(ResourceID resourceID, RetryInfo retryInfo) {} + /** + * 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 failedReconciliation(ResourceID resourceID, RuntimeException exception) {} + default void reconciliationExecutionStarted(HasMetadata resource, Map metadata) {} - default void cleanupDoneFor(ResourceID customResourceUid) {} + default void reconciliationExecutionFinished( + HasMetadata resource, Map metadata) {} - default void finishedReconciliation(ResourceID resourceID) {} + /** + * 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); - T execute(); + /** + * 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; } - default T timeControllerExecution(ControllerExecution execution) { + /** + * 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 index 0fdcab7a56..a5cdb85257 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/BaseControl.java @@ -1,5 +1,6 @@ package io.javaoperatorsdk.operator.api.reconciler; +import java.time.Duration; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -8,7 +9,12 @@ public abstract class BaseControl> { private Long scheduleDelay = null; public T rescheduleAfter(long delay) { - this.scheduleDelay = delay; + rescheduleAfter(Duration.ofMillis(delay)); + return (T) this; + } + + public T rescheduleAfter(Duration delay) { + this.scheduleDelay = delay.toMillis(); return (T) this; } 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 index 85b3a00807..0b0438bc23 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Constants.java @@ -1,11 +1,31 @@ package io.javaoperatorsdk.operator.api.reconciler; +import java.util.Collections; +import java.util.Set; + public final class Constants { - public static final String EMPTY_STRING = ""; public static final String WATCH_CURRENT_NAMESPACE = "JOSDK_WATCH_CURRENT"; - public static final String NO_FINALIZER = "JOSDK_NO_FINALIZER"; - public static final long NO_RECONCILIATION_MAX_INTERVAL = -1L; + 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 index d697325219..f47deb9734 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java @@ -1,14 +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; -public interface Context { +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) { + default Optional getSecondaryResource(Class expectedType) { return getSecondaryResource(expectedType, null); } - Optional getSecondaryResource(Class expectedType, String eventSourceName); + 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 index bd8863cad1..d407ed0fc6 100644 --- 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 @@ -1,27 +1,37 @@ 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 java.util.concurrent.TimeUnit; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter; +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.EMPTY_STRING; + String name() default Constants.NO_VALUE_SET; + + Informer informer() default @Informer; /** - * Optional finalizer name, if it is not provided, one will be automatically generated. If the - * provided value is the value specified by {@link Constants#NO_FINALIZER}, then no finalizer will - * be added to custom resources. + * 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.EMPTY_STRING; + String finalizerName() default Constants.NO_VALUE_SET; /** * If true, will dispatch new event to the controller if generation increased since the last @@ -33,32 +43,38 @@ boolean generationAwareEventProcessing() default true; /** - * Specified which namespaces this Controller monitors for custom resources events. If no - * namespace is specified then the controller will monitor all namespaces by default. + * 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 list of namespaces this controller monitors + * @return the maximal reconciliation interval configuration */ - String[] namespaces() default {}; + MaxReconciliationInterval maxReconciliationInterval() default + @MaxReconciliationInterval(interval = MaxReconciliationInterval.DEFAULT_INTERVAL); /** - * Optional label selector used to identify the set of custom resources the controller will acc - * upon. The label selector can be made of multiple comma separated requirements that acts as a - * logical AND operator. + * Optional {@link Retry} implementation for the associated controller to use. * - * @return the label selector + * @return the class providing the {@link Retry} implementation to use, needs to provide an + * accessible no-arg constructor. */ - String labelSelector() default Constants.EMPTY_STRING; - + Class retry() default GenericRetry.class; /** - * Optional list of classes providing custom {@link ResourceEventFilter}. + * Optional {@link RateLimiter} implementation for the associated controller to use. * - * @return the list of event filters. + * @return the class providing the {@link RateLimiter} implementation to use, needs to provide an + * accessible no-arg constructor. */ - @SuppressWarnings("rawtypes") - Class[] eventFilters() default {}; - - ReconciliationMaxInterval reconciliationMaxInterval() default @ReconciliationMaxInterval( - interval = 10, timeUnit = TimeUnit.HOURS); + 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 index 3d924c2753..2acf8d13ca 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java @@ -1,20 +1,37 @@ 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 { +public class DefaultContext

        implements Context

        { - private final RetryInfo retryInfo; + 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 @@ -22,10 +39,88 @@ 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) { - return controller.getEventSourceManager() - .getResourceEventSourceFor(expectedType, eventSourceName) - .flatMap(es -> es.getAssociated(primaryResource)); + 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 index e27698c989..7160e70830 100644 --- 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 @@ -8,10 +8,25 @@ 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); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ErrorStatusHandler.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ErrorStatusHandler.java deleted file mode 100644 index 22a16e4ccd..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ErrorStatusHandler.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.javaoperatorsdk.operator.api.reconciler; - -import java.util.Optional; - -import io.fabric8.kubernetes.api.model.HasMetadata; - -public interface ErrorStatusHandler { - - /** - *

        - * Reconciler can implement this interface in order to update the status sub-resource in the case - * an exception in thrown. In that case - * {@link #updateErrorStatus(HasMetadata, RetryInfo, RuntimeException)} 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 retryInfo the current retry status - * @param e exception thrown from the reconciler - * @return the updated resource - */ - Optional updateErrorStatus(T resource, RetryInfo retryInfo, RuntimeException e); - -} 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 index 0a93d33d40..5f198a3d01 100644 --- 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 @@ -2,9 +2,9 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.KubernetesClient; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.processing.event.source.EventSource; -import io.javaoperatorsdk.operator.processing.event.source.ResourceCache; +import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache; /** * Contextual information made available to event sources. @@ -13,15 +13,20 @@ */ public class EventSourceContext

        { - private final ResourceCache

        primaryCache; - private final ConfigurationService configurationService; + private final IndexerResourceCache

        primaryCache; + private final ControllerConfiguration

        controllerConfiguration; private final KubernetesClient client; + private final Class

        primaryResourceClass; - public EventSourceContext(ResourceCache

        primaryCache, - ConfigurationService configurationService, KubernetesClient client) { + public EventSourceContext( + IndexerResourceCache

        primaryCache, + ControllerConfiguration

        controllerConfiguration, + KubernetesClient client, + Class

        primaryResourceClass) { this.primaryCache = primaryCache; - this.configurationService = configurationService; + this.controllerConfiguration = controllerConfiguration; this.client = client; + this.primaryResourceClass = primaryResourceClass; } /** @@ -29,31 +34,32 @@ public EventSourceContext(ResourceCache

        primaryCache, * * @return the primary resource cache */ - public ResourceCache

        getPrimaryCache() { + public IndexerResourceCache

        getPrimaryCache() { return primaryCache; } /** - * Retrieves the {@link ConfigurationService} associated with the operator. This allows, in - * particular, to lookup global configuration information such as the configured - * {@link io.javaoperatorsdk.operator.api.monitoring.Metrics} or - * {@link io.javaoperatorsdk.operator.api.config.Cloner} implementations, which could be useful to - * event sources. - * - * @return the {@link ConfigurationService} associated with the operator + * 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 ConfigurationService getConfigurationService() { - return configurationService; + 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. + * 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/EventSourceInitializer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/EventSourceInitializer.java deleted file mode 100644 index 79b15380a5..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/EventSourceInitializer.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.javaoperatorsdk.operator.api.reconciler; - -import java.util.List; - -import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.processing.event.source.EventSource; - -/** - * An interface that a {@link Reconciler} can implement to have the SDK register the provided - * {@link EventSource} - * - * @param

        the primary resource type handled by the associated {@link Reconciler} - */ -public interface EventSourceInitializer

        { - - /** - * Prepares a list of {@link EventSource} implementations to be registered by the SDK. - * - * @param context a {@link EventSourceContext} providing access to information useful to event - * sources - * @return list of event sources to register - */ - List prepareEventSources(EventSourceContext

        context); - -} 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 index 2b725afd73..4075903787 100644 --- 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 @@ -1,43 +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 { +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. + * reconciliation. */ - UpdateControl reconcile(R resource, Context context); + UpdateControl

        reconcile(P resource, Context

        context) throws Exception; /** - * Note that this method is used in combination with finalizers. If automatic finalizer handling - * is turned off for the controller, this method is not called. + * Prepares a map of {@link EventSource} implementations keyed by the name with which they need to + * be registered by the SDK. * - * 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. + * @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. * - *

        - * It's important for implementations of this method to be idempotent, since it can be called - * several times. + *

        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. * - * @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. {@link DeleteControl#noFinalizerRemoval()} if you don't want to remove the - * finalizer to indicate that the resource should not be deleted after all, in which case - * the controller should restore the resource's state appropriately. + *

        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 DeleteControl cleanup(R resource, Context context) { - return DeleteControl.defaultDelete(); + 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/ReconciliationMaxInterval.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconciliationMaxInterval.java deleted file mode 100644 index b2c1f1c255..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconciliationMaxInterval.java +++ /dev/null @@ -1,36 +0,0 @@ -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 ReconciliationMaxInterval { - - /** - * 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 NO_RECONCILIATION_MAX_INTERVAL in {@link Constants} 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/processing/event/source/ResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceCache.java similarity index 76% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceCache.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceCache.java index b0b9e88746..130bd23e8d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceCache.java @@ -1,9 +1,10 @@ -package io.javaoperatorsdk.operator.processing.event.source; +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 { 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 index f746c20dce..26996d6c06 100644 --- 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 @@ -8,7 +8,7 @@ public interface RetryInfo { /** * @return true, if the current attempt is the last one in regard to the retry limit - * configuration. + * 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 index 1762198286..1bd98c12d6 100644 --- 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 @@ -1,52 +1,52 @@ package io.javaoperatorsdk.operator.api.reconciler; +import java.util.Optional; + import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.CustomResource; -@SuppressWarnings("rawtypes") -public class UpdateControl extends BaseControl> { +public class UpdateControl

        extends BaseControl> { - private final T resource; - private final boolean updateStatus; - private final boolean updateResource; + private final P resource; + private final boolean patchResource; + private final boolean patchStatus; - private UpdateControl( - T resource, boolean updateStatus, boolean updateResource) { - if ((updateResource || updateStatus) && resource == null) { + 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.updateStatus = updateStatus; - this.updateResource = updateResource; + this.patchResource = patchResource; + this.patchStatus = patchStatus; } /** - * Creates an update control instance that instructs the framework to do an update on resource - * itself, not on the status. Note that usually as a results of a reconciliation should be a - * status update not an update to the resource itself. + * 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 custom resource type - * @param customResource customResource to use for update - * @return initialized update control + * @param resource type + * @param customResource the custom resource with target status + * @return UpdateControl instance */ - public static UpdateControl updateResource(T customResource) { + public static UpdateControl patchStatus(T customResource) { return new UpdateControl<>(customResource, false, true); } - public static UpdateControl updateStatus( - T customResource) { + public static UpdateControl patchResource(T customResource) { return new UpdateControl<>(customResource, true, false); } /** - * As a results of this there will be two call to K8S API. First the custom resource will be - * updates then the status sub-resource. - * - * @param resource type - * @param customResource - custom resource to use in both API calls + * @param customResource to update * @return UpdateControl instance + * @param resource type */ - public static UpdateControl updateResourceAndStatus( - T customResource) { + public static UpdateControl patchResourceAndStatus(T customResource) { return new UpdateControl<>(customResource, true, true); } @@ -54,23 +54,23 @@ public static UpdateControl noUpdate() { return new UpdateControl<>(null, false, false); } - public T getResource() { - return resource; + public Optional

        getResource() { + return Optional.ofNullable(resource); } - public boolean isUpdateStatus() { - return updateStatus; + public boolean isPatchResource() { + return patchResource; } - public boolean isUpdateResource() { - return updateResource; + public boolean isPatchStatus() { + return patchStatus; } public boolean isNoUpdate() { - return !updateResource && !updateStatus; + return !patchResource && !patchStatus; } - public boolean isUpdateResourceAndStatus() { - return updateResource && updateStatus; + 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..720ce0227c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/health/InformerWrappingEventSourceHealthIndicator.java @@ -0,0 +1,18 @@ +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 hasNonHealthy = + informerHealthIndicators().values().stream().anyMatch(i -> i.getStatus() != Status.HEALTHY); + return hasNonHealthy ? 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 index f9a600f1a7..a53d52c429 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java @@ -1,6 +1,14 @@ 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; @@ -12,45 +20,104 @@ import io.javaoperatorsdk.operator.CustomResourceUtils; import io.javaoperatorsdk.operator.MissingCRDException; import io.javaoperatorsdk.operator.OperatorException; -import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.RegisteredController; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.config.Version; +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.EventSourceInitializer; +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; -@SuppressWarnings({"unchecked"}) -public class Controller implements Reconciler, - LifecycleAware, EventSourceInitializer { - private final Reconciler reconciler; - private final ControllerConfiguration configuration; - private final KubernetesClient kubernetesClient; - private EventSourceManager eventSourceManager; - private volatile ConfigurationService configurationService; +import static io.javaoperatorsdk.operator.api.reconciler.Constants.WATCH_CURRENT_NAMESPACE; + +@SuppressWarnings({"unchecked", "rawtypes"}) +@Ignore +public class Controller

        + implements Reconciler

        , LifecycleAware, Cleaner

        , RegisteredController

        { - public Controller(Reconciler reconciler, - ControllerConfiguration configuration, + 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 DeleteControl cleanup(R resource, Context context) { - return metrics().timeControllerExecution( + public UpdateControl

        reconcile(P resource, Context

        context) throws Exception { + return metrics.timeControllerExecution( new ControllerExecution<>() { @Override public String name() { - return "cleanup"; + return RECONCILE; } @Override @@ -59,58 +126,155 @@ public String controllerName() { } @Override - public String successTypeName(DeleteControl deleteControl) { - return deleteControl.isRemoveFinalizer() ? "delete" : "finalizerNotRemoved"; + public String successTypeName(UpdateControl

        result) { + String successType = RESOURCE; + if (result.isPatchStatus()) { + successType = STATUS; + } + if (result.isPatchResourceAndStatus()) { + successType = BOTH; + } + return successType; } @Override - public DeleteControl execute() { - return reconciler.cleanup(resource, context); + public ResourceID resourceID() { + return ResourceID.fromResource(resource); } - }); - } - @Override - public UpdateControl reconcile(R resource, Context context) { - return metrics().timeControllerExecution( - new ControllerExecution<>() { @Override - public String name() { - return "reconcile"; + public Map metadata() { + return Map.of(Constants.RESOURCE_GVK_KEY, associatedGVK); } @Override - public String controllerName() { - return configuration.getName(); + 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 String successTypeName(UpdateControl result) { - String successType = "resource"; - if (result.isUpdateStatus()) { - successType = "status"; + @Override + public DeleteControl cleanup(P resource, Context

        context) { + try { + return metrics.timeControllerExecution( + new ControllerExecution<>() { + @Override + public String name() { + return CLEANUP; } - if (result.isUpdateResourceAndStatus()) { - successType = "both"; + + @Override + public String controllerName() { + return configuration.getName(); } - return successType; - } - @Override - public UpdateControl execute() { - return reconciler.reconcile(resource, context); - } - }); + @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 Metrics metrics() { - final var metrics = configurationService().getMetrics(); - return metrics != null ? metrics : Metrics.NOOP; + private DeleteControl workflowCleanupResultToDefaultDelete( + WorkflowCleanupResult workflowCleanupResult) { + if (workflowCleanupResult == null) { + return DeleteControl.defaultDelete(); + } else { + return workflowCleanupResult.allPostConditionsMet() + ? DeleteControl.defaultDelete() + : DeleteControl.noFinalizerRemoval(); + } } - @Override - public List prepareEventSources(EventSourceContext context) { - throw new UnsupportedOperationException("This method should never be called directly"); + 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 @@ -136,84 +300,106 @@ public String toString() { return "'" + configuration.getName() + "' Controller"; } - public Reconciler getReconciler() { + public Reconciler

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

        getConfiguration() { return configuration; } + @Override + public ControllerHealthInfo getControllerHealthInfo() { + return controllerHealthInfo; + } + public KubernetesClient getClient() { return kubernetesClient; } - public MixedOperation, Resource> getCRClient() { + 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)}, + * 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 void start() throws OperatorException { - final Class resClass = configuration.getResourceClass(); + 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 - final CustomResourceDefinition crd; // todo: check proper CRD spec version based on config - if (configurationService().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); - } - - eventSourceManager = new EventSourceManager<>(this); - if (reconciler instanceof EventSourceInitializer) { - ((EventSourceInitializer) reconciler) - .prepareEventSources(new EventSourceContext<>( - eventSourceManager.getControllerResourceEventSource().getResourceCache(), - configurationService(), kubernetesClient)) - .forEach(eventSourceManager::registerEventSource); - } - + validateCRDWithLocalModelIfRequired(resClass, controllerName, crdName, specVersion); eventSourceManager.start(); + if (startEventProcessor) { + eventProcessor.start(); + } + log.info("'{}' controller started", controllerName); } catch (MissingCRDException e) { - throwMissingCRDException(crdName, specVersion, controllerName); + stop(); + throwMissingCRDException(e.getCrdName(), e.getSpecVersion(), controllerName); } } - private ConfigurationService configurationService() { - if (configurationService == null) { - configurationService = configuration.getConfigurationService(); - // make sure we always have a default configuration service - if (configurationService == null) { - // we shouldn't need to register the configuration with the default service - configurationService = new BaseConfigurationService(Version.UNKNOWN) { - @Override - public boolean checkCRDAndValidateLocalModel() { - return false; - } - }; + 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); } - return configurationService; + 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) { @@ -240,17 +426,64 @@ private void failOnMissingCurrentNS() { throw new OperatorException( "Controller '" + configuration.getName() - + "' is configured to watch the current namespace but it couldn't be inferred from the current configuration."); + + "' is configured to watch the current namespace but it couldn't be inferred from" + + " the current configuration."); } } - public EventSourceManager getEventSourceManager() { + public EventSourceManager

        getEventSourceManager() { return eventSourceManager; } - public void stop() { + 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/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 index 4578c31744..12348ed932 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/MDCUtils.java @@ -3,6 +3,7 @@ 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 { @@ -14,36 +15,52 @@ public class MDCUtils { private static final String RESOURCE_VERSION = "resource.resourceVersion"; private static final String GENERATION = "resource.generation"; private static final String UID = "resource.uid"; + private static final String NO_NAMESPACE = "no namespace"; + private static final boolean enabled = + Utils.getBooleanFromSystemPropsOrDefault(Utils.USE_MDC_ENV_KEY, true); public static void addResourceIDInfo(ResourceID resourceID) { - MDC.put(NAME, resourceID.getName()); - MDC.put(NAMESPACE, resourceID.getNamespace().orElse("no namespace")); + if (enabled) { + MDC.put(NAME, resourceID.getName()); + MDC.put(NAMESPACE, resourceID.getNamespace().orElse(NO_NAMESPACE)); + } } public static void removeResourceIDInfo() { - MDC.remove(NAME); - MDC.remove(NAMESPACE); + if (enabled) { + MDC.remove(NAME); + MDC.remove(NAMESPACE); + } } public static void addResourceInfo(HasMetadata resource) { - MDC.put(API_VERSION, resource.getApiVersion()); - MDC.put(KIND, resource.getKind()); - MDC.put(NAME, resource.getMetadata().getName()); - MDC.put(NAMESPACE, resource.getMetadata().getNamespace()); - MDC.put(RESOURCE_VERSION, resource.getMetadata().getResourceVersion()); - if (resource.getMetadata().getGeneration() != null) { - MDC.put(GENERATION, resource.getMetadata().getGeneration().toString()); + 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()); + } } - MDC.put(UID, resource.getMetadata().getUid()); } public static void removeResourceInfo() { - MDC.remove(API_VERSION); - MDC.remove(KIND); - MDC.remove(NAME); - MDC.remove(NAMESPACE); - MDC.remove(RESOURCE_VERSION); - MDC.remove(GENERATION); - MDC.remove(UID); + 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 index 227a297795..9ed00625bb 100644 --- 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 @@ -16,17 +16,13 @@ public ResourceID getRelatedCustomResourceID() { @Override public String toString() { - return "Event{" + - "relatedCustomResource=" + relatedCustomResource + - '}'; + return "Event{" + "relatedCustomResource=" + relatedCustomResource + '}'; } @Override public boolean equals(Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; Event event = (Event) o; return Objects.equals(relatedCustomResource, event.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 index 73f7867da4..064b566220 100644 --- 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 @@ -3,5 +3,4 @@ public interface EventHandler { void handleEvent(Event event); - } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventMarker.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventMarker.java deleted file mode 100644 index a26c9c7829..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventMarker.java +++ /dev/null @@ -1,94 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import static io.javaoperatorsdk.operator.processing.event.EventMarker.EventingState.NO_EVENT_PRESENT; - -/** - * 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. - */ -class EventMarker { - - public enum EventingState { - /** Event but NOT Delete event present */ - EVENT_PRESENT, NO_EVENT_PRESENT, - /** Delete event present, from this point other events are not relevant */ - DELETE_EVENT_PRESENT, - } - - private final HashMap eventingState = new HashMap<>(); - - private EventingState getEventingState(ResourceID resourceID) { - EventingState actualState = eventingState.get(resourceID); - return actualState == null ? NO_EVENT_PRESENT : actualState; - } - - private void setEventingState(ResourceID resourceID, EventingState state) { - eventingState.put(resourceID, state); - } - - public void markEventReceived(Event event) { - markEventReceived(event.getRelatedCustomResourceID()); - } - - public void markEventReceived(ResourceID resourceID) { - if (deleteEventPresent(resourceID)) { - throw new IllegalStateException("Cannot receive event after a delete event received"); - } - setEventingState(resourceID, EventingState.EVENT_PRESENT); - } - - public void unMarkEventReceived(ResourceID resourceID) { - var actualState = getEventingState(resourceID); - switch (actualState) { - case EVENT_PRESENT: - setEventingState(resourceID, - NO_EVENT_PRESENT); - break; - case DELETE_EVENT_PRESENT: - throw new IllegalStateException("Cannot unmark delete event."); - } - } - - public void markDeleteEventReceived(Event event) { - markDeleteEventReceived(event.getRelatedCustomResourceID()); - } - - public void markDeleteEventReceived(ResourceID resourceID) { - setEventingState(resourceID, EventingState.DELETE_EVENT_PRESENT); - } - - public boolean deleteEventPresent(ResourceID resourceID) { - return getEventingState(resourceID) == EventingState.DELETE_EVENT_PRESENT; - } - - public boolean eventPresent(ResourceID resourceID) { - var actualState = getEventingState(resourceID); - return actualState == EventingState.EVENT_PRESENT; - } - - public boolean noEventPresent(ResourceID resourceID) { - var actualState = getEventingState(resourceID); - return actualState == NO_EVENT_PRESENT; - } - - public void cleanup(ResourceID resourceID) { - eventingState.remove(resourceID); - } - - public List resourceIDsWithEventPresent() { - return eventingState.entrySet().stream() - .filter(e -> e.getValue() != NO_EVENT_PRESENT) - .map(Map.Entry::getKey) - .collect(Collectors.toList()); - } - -} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java index 29f9adab55..e029e287a0 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java @@ -1,164 +1,180 @@ package io.javaoperatorsdk.operator.processing.event; +import java.net.HttpURLConnection; +import java.time.Duration; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.concurrent.ExecutorService; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.locks.ReentrantLock; 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.ExecutorServiceManager; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.monitoring.Metrics; -import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; +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.GenericRetry; import io.javaoperatorsdk.operator.processing.retry.Retry; import io.javaoperatorsdk.operator.processing.retry.RetryExecution; import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getName; -import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; -class EventProcessor implements EventHandler, LifecycleAware { +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 final Set underProcessing = new HashSet<>(); - private final ReconciliationDispatcher reconciliationDispatcher; + private volatile boolean running; + private final ControllerConfiguration controllerConfiguration; + private final ReconciliationDispatcher

        reconciliationDispatcher; private final Retry retry; - private final Map retryState = new HashMap<>(); - private final ExecutorService executor; - private final String controllerName; - private final ReentrantLock lock = new ReentrantLock(); private final Metrics metrics; - private volatile boolean running; - private final Cache cache; - private final EventSourceManager eventSourceManager; - private final EventMarker eventMarker = new EventMarker(); - - EventProcessor(EventSourceManager eventSourceManager) { + 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.getControllerResourceEventSource().getResourceCache(), - ExecutorServiceManager.instance().executorService(), - eventSourceManager.getController().getConfiguration().getName(), + eventSourceManager.getController().getConfiguration(), new ReconciliationDispatcher<>(eventSourceManager.getController()), - GenericRetry.fromConfiguration( - eventSourceManager.getController().getConfiguration().getRetryConfiguration()), - eventSourceManager.getController().getConfiguration().getConfigurationService() == null - ? Metrics.NOOP - : eventSourceManager - .getController() - .getConfiguration() - .getConfigurationService() - .getMetrics(), - eventSourceManager); + eventSourceManager, + configurationService.getMetrics(), + eventSourceManager.getControllerEventSource()); } + @SuppressWarnings("rawtypes") EventProcessor( - ReconciliationDispatcher reconciliationDispatcher, - EventSourceManager eventSourceManager, - String relatedControllerName, - Retry retry, + ControllerConfiguration controllerConfiguration, + ReconciliationDispatcher

        reconciliationDispatcher, + EventSourceManager

        eventSourceManager, Metrics metrics) { this( - eventSourceManager.getControllerResourceEventSource().getResourceCache(), - null, - relatedControllerName, + controllerConfiguration, reconciliationDispatcher, - retry, + eventSourceManager, metrics, - eventSourceManager); + eventSourceManager.getControllerEventSource()); } + @SuppressWarnings({"rawtypes", "unchecked"}) private EventProcessor( - Cache cache, - ExecutorService executor, - String relatedControllerName, - ReconciliationDispatcher reconciliationDispatcher, - Retry retry, + ControllerConfiguration controllerConfiguration, + ReconciliationDispatcher

        reconciliationDispatcher, + EventSourceManager

        eventSourceManager, Metrics metrics, - EventSourceManager eventSourceManager) { + Cache

        cache) { + this.controllerConfiguration = controllerConfiguration; this.running = false; - this.executor = - executor == null - ? new ScheduledThreadPoolExecutor( - ConfigurationService.DEFAULT_RECONCILIATION_THREADS_NUMBER) - : executor; - this.controllerName = relatedControllerName; this.reconciliationDispatcher = reconciliationDispatcher; - this.retry = retry; + this.retry = controllerConfiguration.getRetry(); this.cache = cache; this.metrics = metrics != null ? metrics : Metrics.NOOP; this.eventSourceManager = eventSourceManager; - } - - EventMarker getEventMarker() { - return eventMarker; + 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 void handleEvent(Event event) { - lock.lock(); + 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); - handleEventMarking(event); + 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(resourceID); + handleMarkedEventForResource(state); } finally { - lock.unlock(); MDCUtils.removeResourceIDInfo(); } } - private void handleMarkedEventForResource(ResourceID resourceID) { - if (!eventMarker.deleteEventPresent(resourceID)) { - submitReconciliationExecution(resourceID); - } else { - cleanupForDeletedEvent(resourceID); + private void handleMarkedEventForResource(ResourceState state) { + if (state.deleteEventPresent()) { + cleanupForDeletedEvent(state.getId()); + } else if (!state.processedMarkForDeletionPresent()) { + submitReconciliationExecution(state); } } - private void submitReconciliationExecution(ResourceID resourceID) { + private void submitReconciliationExecution(ResourceState state) { try { - boolean controllerUnderExecution = isControllerUnderExecution(resourceID); - Optional latest = cache.get(resourceID); - latest.ifPresent(MDCUtils::addResourceInfo); - if (!controllerUnderExecution && latest.isPresent()) { - setUnderExecutionProcessing(resourceID); - final var retryInfo = retryInfo(resourceID); - ExecutionScope executionScope = new ExecutionScope<>(latest.get(), retryInfo); - eventMarker.unMarkEventReceived(resourceID); - metrics.reconcileCustomResource(resourceID, retryInfo); + 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 ControllerExecution(executionScope)); + executor.execute(new ReconcilerExecutor(resourceID, executionScope)); } else { log.debug( - "Skipping executing controller for resource id: {}. Controller in execution: {}. Latest Resource present: {}", + "Skipping executing controller for resource id: {}. Controller in execution: {}. Latest" + + " Resource present: {}", resourceID, controllerUnderExecution, - latest.isPresent()); - if (latest.isEmpty()) { - log.debug("no custom resource found in cache for ResourceID: {}", resourceID); + 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 { @@ -166,106 +182,141 @@ private void submitReconciliationExecution(ResourceID resourceID) { } } - private void handleEventMarking(Event event) { - if (event instanceof ResourceEvent - && ((ResourceEvent) event).getAction() == ResourceAction.DELETED) { - eventMarker.markDeleteEventReceived(event); - } else if (!eventMarker.deleteEventPresent(event.getRelatedCustomResourceID())) { - eventMarker.markEventReceived(event); + 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 RetryInfo retryInfo(ResourceID customResourceUid) { - return retryState.get(customResourceUid); + private void markEventReceived(ResourceState state) { + log.debug("Marking event received for: {}", state.getId()); + state.markEventReceived(); } - void eventProcessingFinished( - ExecutionScope executionScope, PostExecutionControl postExecutionControl) { - lock.lock(); - try { - if (!running) { - return; - } - ResourceID resourceID = executionScope.getCustomResourceID(); - log.debug( - "Event processing finished. Scope: {}, PostExecutionControl: {}", - executionScope, - postExecutionControl); - unsetUnderExecution(resourceID); - - // 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() - && !eventMarker.deleteEventPresent(resourceID)) { - handleRetryOnException( - executionScope, postExecutionControl.getRuntimeException().orElseThrow()); - return; - } - cleanupOnSuccessfulExecution(executionScope); - metrics.finishedReconciliation(resourceID); - if (eventMarker.deleteEventPresent(resourceID)) { - cleanupForDeletedEvent(executionScope.getCustomResourceID()); - } else { - if (eventMarker.eventPresent(resourceID)) { - if (isCacheReadyForInstantReconciliation(executionScope, postExecutionControl)) { - submitReconciliationExecution(resourceID); - } else { - postponeReconciliationAndHandleCacheSyncEvent(resourceID); - } - } else { - reScheduleExecutionIfInstructed(postExecutionControl, executionScope.getResource()); - } - } - } finally { - lock.unlock(); - } + private boolean isResourceMarkedForDeletion(ResourceEvent resourceEvent) { + return resourceEvent.getResource().map(HasMetadata::isMarkedForDeletion).orElse(false); } - private void postponeReconciliationAndHandleCacheSyncEvent(ResourceID resourceID) { - eventSourceManager.getControllerResourceEventSource().whitelistNextEvent(resourceID); + 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)); } - private boolean isCacheReadyForInstantReconciliation( - ExecutionScope executionScope, PostExecutionControl postExecutionControl) { - if (!postExecutionControl.customResourceUpdatedDuringExecution()) { - return true; + 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()); + } } - String originalResourceVersion = getVersion(executionScope.getResource()); - String customResourceVersionAfterExecution = - getVersion( - postExecutionControl - .getUpdatedCustomResource() - .orElseThrow( - () -> new IllegalStateException( - "Updated custom resource must be present at this point of time"))); - String cachedCustomResourceVersion = - getVersion( - cache - .get(executionScope.getCustomResourceID()) - .orElseThrow( - () -> new IllegalStateException( - "Cached custom resource must be present at this point"))); - - if (cachedCustomResourceVersion.equals(customResourceVersionAfterExecution)) { - return true; + } + + /** + * 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()); } - // If the cached resource version equals neither the version before nor after execution - // probably an update happened on the custom resource independent of the framework during - // reconciliation. We cannot tell at this point if it happened before our update or before. - // (Well we could if we would parse resource version, but that should not be done by definition) - return !cachedCustomResourceVersion.equals(originalResourceVersion); } private void reScheduleExecutionIfInstructed( - PostExecutionControl postExecutionControl, R customResource) { + PostExecutionControl

        postExecutionControl, P customResource) { + postExecutionControl .getReScheduleDelay() - .ifPresent(delay -> retryEventSource().scheduleOnce(customResource, delay)); + .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() { + TimerEventSource

        retryEventSource() { return eventSourceManager.retryEventSource(); } @@ -274,65 +325,94 @@ TimerEventSource retryEventSource() { * events (received meanwhile retry is in place or already in buffer) instantly or always wait * according to the retry timing if there was an exception. */ - private void handleRetryOnException( - ExecutionScope executionScope, RuntimeException exception) { - RetryExecution execution = getOrInitRetryExecution(executionScope); - var customResourceID = executionScope.getCustomResourceID(); - boolean eventPresent = eventMarker.eventPresent(customResourceID); - eventMarker.markEventReceived(customResourceID); + 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: {}", customResourceID); - submitReconciliationExecution(customResourceID); + log.debug("New events exists for for resource id: {}", resourceID); + submitReconciliationExecution(state); return; } - Optional nextDelay = execution.nextDelay(); + Optional nextDelay = state.getRetry().nextDelay(); nextDelay.ifPresentOrElse( delay -> { log.debug( - "Scheduling timer event for retry with delay:{} for resource: {}", - delay, - customResourceID); - metrics.failedReconciliation(customResourceID, exception); - retryEventSource().scheduleOnce(executionScope.getResource(), delay); + "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 {}", executionScope)); + () -> { + log.error("Exhausted retries for scope {}.", executionScope); + scheduleExecutionForMaxReconciliationInterval(executionScope.getResource()); + }); } - private void cleanupOnSuccessfulExecution(ExecutionScope executionScope) { + 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()) { - retryState.remove(executionScope.getCustomResourceID()); + resourceStateManager.getOrCreate(executionScope.getResourceID()).setRetry(null); } - retryEventSource().cancelOnceSchedule(executionScope.getCustomResourceID()); + retryEventSource().cancelOnceSchedule(executionScope.getResourceID()); } - private RetryExecution getOrInitRetryExecution(ExecutionScope executionScope) { - RetryExecution retryExecution = retryState.get(executionScope.getCustomResourceID()); + private ResourceState getOrInitRetryExecution(ExecutionScope

        executionScope) { + final var state = resourceStateManager.getOrCreate(executionScope.getResourceID()); + RetryExecution retryExecution = state.getRetry(); if (retryExecution == null) { retryExecution = retry.initExecution(); - retryState.put(executionScope.getCustomResourceID(), retryExecution); + state.setRetry(retryExecution); } - return retryExecution; - } - - private void cleanupForDeletedEvent(ResourceID customResourceUid) { - eventMarker.cleanup(customResourceUid); - metrics.cleanupDoneFor(customResourceUid); + return state; } - private boolean isControllerUnderExecution(ResourceID customResourceUid) { - return underProcessing.contains(customResourceUid); + private void cleanupForDeletedEvent(ResourceID resourceID) { + log.debug("Cleaning up for delete event for: {}", resourceID); + resourceStateManager.remove(resourceID); + metrics.cleanupDoneFor(resourceID, metricsMetadata); } - private void setUnderExecutionProcessing(ResourceID customResourceUid) { - underProcessing.add(customResourceUid); + private boolean isControllerUnderExecution(ResourceState state) { + return state.isUnderProcessing(); } - private void unsetUnderExecution(ResourceID customResourceUid) { - underProcessing.remove(customResourceUid); + private void unsetUnderExecution(ResourceID resourceID) { + resourceStateManager.getOrCreate(resourceID).setUnderProcessing(false); } private boolean isRetryConfigured() { @@ -340,51 +420,70 @@ private boolean isRetryConfigured() { } @Override - public void stop() { - lock.lock(); - try { - this.running = false; - } finally { - lock.unlock(); - } + public synchronized void stop() { + this.running = false; } @Override - public void start() throws OperatorException { - lock.lock(); - try { - this.running = true; - handleAlreadyMarkedEvents(); - } finally { - lock.unlock(); - } + 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 (ResourceID resourceID : eventMarker.resourceIDsWithEventPresent()) { - handleMarkedEventForResource(resourceID); + for (var state : resourceStateManager.resourcesWithEventPresent()) { + log.debug("Handling already marked event on start. State: {}", state); + handleMarkedEventForResource(state); } } - private class ControllerExecution implements Runnable { - private final ExecutionScope executionScope; + private class ReconcilerExecutor implements Runnable { + private final ExecutionScope

        executionScope; + private final ResourceID resourceID; - private ControllerExecution(ExecutionScope executionScope) { + 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()); - thread.setName("EventHandler-" + controllerName); - PostExecutionControl postExecutionControl = + 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(); @@ -393,7 +492,21 @@ public void run() { @Override public String toString() { - return controllerName + " -> " + executionScope; + 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 index 22920ac7a2..8b07bf110b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java @@ -1,17 +1,13 @@ package io.javaoperatorsdk.operator.processing.event; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.concurrent.ConcurrentNavigableMap; -import java.util.concurrent.ConcurrentSkipListMap; -import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,285 +15,251 @@ 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.ResourceEventSource; -import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerResourceEventSource; +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 { +public class EventSourceManager

        + implements LifecycleAware, EventSourceRetriever

        { private static final Logger log = LoggerFactory.getLogger(EventSourceManager.class); - private final ReentrantLock lock = new ReentrantLock(); - private final EventSources eventSources = new EventSources<>(); - private final EventProcessor eventProcessor; - private final Controller controller; + private final EventSources

        eventSources; + private final Controller

        controller; + private final ExecutorServiceManager executorServiceManager; - EventSourceManager(EventProcessor eventProcessor) { - this.eventProcessor = eventProcessor; - controller = null; - registerEventSource(eventSources.retryEventSource()); + public EventSourceManager(Controller

        controller) { + this(controller, new EventSources<>()); } - public EventSourceManager(Controller controller) { + 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 - final var controllerEventSource = eventSources.initControllerEventSource(controller); - this.eventProcessor = new EventProcessor<>(this); - registerEventSource(eventSources.retryEventSource()); - registerEventSource(controllerEventSource); + 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}). + * (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 ControllerResourceEventSource} , which is started first. + *

        Now the event sources are also started sequentially, mainly because others might depend on + * {@link ControllerEventSource} , which is started first. */ @Override - public void start() { - lock.lock(); + 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 { - log.debug("Starting event sources."); - for (var eventSource : eventSources) { - try { - eventSource.start(); - } catch (Exception e) { - log.warn("Error starting {}", eventSource, e); - } - } - eventProcessor.start(); - } finally { - lock.unlock(); + 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; } - @Override - public void stop() { - lock.lock(); + private Void stopEventSource(EventSource eventSource) { try { - log.debug("Closing event sources."); - for (var eventSource : eventSources) { - try { - eventSource.stop(); - } catch (Exception e) { - log.warn("Error closing {} -> {}", eventSource, e); - } - } - eventSources.clear(); - } finally { - lock.unlock(); + logEventSourceEvent(eventSource, "Stopping"); + eventSource.stop(); + logEventSourceEvent(eventSource, "Stopped"); + } catch (Exception e) { + log.warn("Error closing {} -> {}", eventSource.name(), e); } - eventProcessor.stop(); + return null; } - public final void registerEventSource(EventSource eventSource) + @SuppressWarnings("rawtypes") + public final synchronized void registerEventSource(EventSource eventSource) throws OperatorException { Objects.requireNonNull(eventSource, "EventSource must not be null"); - lock.lock(); try { - eventSources.add(eventSource); - eventSource.setEventHandler(eventProcessor); - } catch (Throwable e) { - if (e instanceof IllegalStateException || e instanceof MissingCRDException) { - // leave untouched - throw e; + 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.getClass().getName(), e); - } finally { - lock.unlock(); - } - } - - public void broadcastOnResourceEvent(ResourceAction action, R resource, R oldResource) { - for (var eventSource : eventSources) { - if (eventSource instanceof ResourceEventAware) { - var lifecycleAwareES = ((ResourceEventAware) eventSource); - switch (action) { - case ADDED: - lifecycleAwareES.onResourceCreated(resource); - break; - case UPDATED: - lifecycleAwareES.onResourceUpdated(resource, oldResource); - break; - case DELETED: - lifecycleAwareES.onResourceDeleted(resource); - break; - } - } + "Couldn't register event source: " + + eventSource.name() + + " for " + + controller.getConfiguration().getName() + + " controller", + e); } } - EventHandler getEventHandler() { - return eventProcessor; + @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; + } + } + }); } - Set getRegisteredEventSources() { - return eventSources.all(); + 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 ControllerResourceEventSource getControllerResourceEventSource() { - return eventSources.controllerResourceEventSource; + public Set> getRegisteredEventSources() { + return eventSources.flatMappedSources().collect(Collectors.toCollection(LinkedHashSet::new)); } - public Optional> getResourceEventSourceFor( - Class dependentType) { - return getResourceEventSourceFor(dependentType, null); + @SuppressWarnings("rawtypes") + public List allEventSources() { + return eventSources.allEventSources().toList(); } - public Optional> getResourceEventSourceFor( - Class dependentType, String qualifier) { - if (dependentType == null) { - return Optional.empty(); - } - String name = qualifier == null ? "" : qualifier; - final var eventSource = eventSources.get(dependentType, name); - return Optional.ofNullable(eventSource); + @SuppressWarnings("unused") + public Stream> getEventSourcesStream() { + return eventSources.flatMappedSources(); } - TimerEventSource retryEventSource() { - return eventSources.retryAndRescheduleTimerEventSource; + @Override + public ControllerEventSource

        getControllerEventSource() { + return eventSources.controllerEventSource(); } - Controller getController() { - return controller; + public List> getEventSourcesFor(Class dependentType) { + return eventSources.getEventSources(dependentType); } - private static class EventSources implements Iterable { - private final ConcurrentNavigableMap> sources = - new ConcurrentSkipListMap<>(); - private final TimerEventSource retryAndRescheduleTimerEventSource = new TimerEventSource<>(); - private ControllerResourceEventSource controllerResourceEventSource; - - - ControllerResourceEventSource initControllerEventSource(Controller controller) { - controllerResourceEventSource = new ControllerResourceEventSource<>(controller); - return controllerResourceEventSource; - } - - TimerEventSource retryEventSource() { - return retryAndRescheduleTimerEventSource; - } - - @Override - public Iterator iterator() { - return sources.values().stream().flatMap(Collection::stream).iterator(); - } - - public Set all() { - return new LinkedHashSet<>(sources.values().stream().flatMap(Collection::stream) - .collect(Collectors.toList())); - } - - public void clear() { - sources.clear(); - } - - public boolean contains(EventSource source) { - final var eventSources = sources.get(keyFor(source)); - if (eventSources == null || eventSources.isEmpty()) { - return false; - } - return findMatchingSource(name(source), eventSources).isPresent(); - } - - public void add(EventSource eventSource) { - if (contains(eventSource)) { - throw new IllegalArgumentException("An event source is already registered for the " - + keyAsString(getDependentType(eventSource), name(eventSource)) - + " class/name combination"); + @Override + public EventSource dynamicallyRegisterEventSource(EventSource eventSource) { + synchronized (this) { + var actual = eventSources.existingEventSourceByName(eventSource.name()); + if (actual != null) { + eventSource = actual; + } else { + registerEventSource(eventSource); } - sources.computeIfAbsent(keyFor(eventSource), k -> new ArrayList<>()).add(eventSource); - } - - private Class getDependentType(EventSource source) { - return source instanceof ResourceEventSource - ? ((ResourceEventSource) source).getResourceClass() - : source.getClass(); - } - - private String name(EventSource source) { - return source.name(); - } - - private String keyFor(EventSource source) { - return keyFor(getDependentType(source)); } + // 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; + } - private String keyFor(Class dependentType) { - var key = dependentType.getCanonicalName(); - - // make sure timer event source is started first, then controller event source - // this is needed so that these sources are set when informer sources start so that events can - // properly be processed - if (controllerResourceEventSource != null - && key.equals(controllerResourceEventSource.getResourceClass().getCanonicalName())) { - key = 1 + "-" + key; - } else if (key.equals(retryAndRescheduleTimerEventSource.getClass().getCanonicalName())) { - key = 0 + "-" + key; - } - return key; + @Override + public synchronized Optional> dynamicallyDeRegisterEventSource( + String name) { + @SuppressWarnings("unchecked") + EventSource es = eventSources.remove(name); + if (es != null) { + es.stop(); } + return Optional.ofNullable(es); + } - public ResourceEventSource get(Class dependentType, String name) { - final var sourcesForType = sources.get(keyFor(dependentType)); - if (sourcesForType == null || sourcesForType.isEmpty()) { - return null; - } - - final var size = sourcesForType.size(); - final EventSource source; - if (size == 1) { - source = sourcesForType.get(0); - } 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: " - + sourcesForType.stream().map(this::name).collect(Collectors.joining(","))); - } - source = findMatchingSource(name, sourcesForType).orElse(null); - - if (source == null) { - return null; - } - } + @Override + public EventSourceContext

        eventSourceContextForDynamicRegistration() { + return controller.eventSourceContext(); + } - if (!(source instanceof ResourceEventSource)) { - throw new IllegalArgumentException(source + " associated with " - + keyAsString(dependentType, name) + " is not a " - + ResourceEventSource.class.getSimpleName()); - } - final var res = (ResourceEventSource) source; - final var resourceClass = res.getResourceClass(); - if (!resourceClass.isAssignableFrom(dependentType)) { - throw new IllegalArgumentException(source + " associated with " - + keyAsString(dependentType, name) - + " is handling " + resourceClass.getName() + " resources but asked for " - + dependentType.getName()); - } - return res; - } + @Override + public EventSource getEventSourceFor(Class dependentType, String name) { + Objects.requireNonNull(dependentType, "dependentType is Mandatory"); + return eventSources.get(dependentType, name); + } - private Optional findMatchingSource(String name, - List sourcesForType) { - return sourcesForType.stream().filter(es -> name(es).equals(name)).findAny(); - } + TimerEventSource

        retryEventSource() { + return eventSources.retryEventSource(); + } - @SuppressWarnings("rawtypes") - private String keyAsString(Class dependentType, String name) { - return name != null && name.length() > 0 - ? "(" + dependentType.getName() + ", " + name + ")" - : dependentType.getName(); - } + 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 index 037e3c4f5f..90899a6e1a 100644 --- 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 @@ -6,30 +6,37 @@ class ExecutionScope { // the latest custom resource from cache - private final R resource; + private R resource; private final RetryInfo retryInfo; - public ExecutionScope(R resource, RetryInfo retryInfo) { - this.resource = resource; + ExecutionScope(RetryInfo retryInfo) { this.retryInfo = retryInfo; } + public ExecutionScope setResource(R resource) { + this.resource = resource; + return this; + } + public R getResource() { return resource; } - public ResourceID getCustomResourceID() { + public ResourceID getResourceID() { return ResourceID.fromResource(resource); } @Override public String toString() { - return "ExecutionScope{" - + " resource id: " - + ResourceID.fromResource(resource) - + ", version: " - + resource.getMetadata().getResourceVersion() - + '}'; + if (resource == null) { + return "ExecutionScope{resource: null}"; + } else + return "ExecutionScope{" + + " resource id: " + + ResourceID.fromResource(resource) + + ", version: " + + resource.getMetadata().getResourceVersion() + + '}'; } public RetryInfo getRetryInfo() { 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 index 5d9a623e20..42311c1cb5 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/PostExecutionControl.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/PostExecutionControl.java @@ -6,49 +6,55 @@ final class PostExecutionControl { - private final boolean onlyFinalizerHandled; + private final boolean finalizerRemoved; private final R updatedCustomResource; - private final RuntimeException runtimeException; + private final boolean updateIsStatusPatch; + private final Exception runtimeException; private Long reScheduleDelay = null; private PostExecutionControl( - boolean onlyFinalizerHandled, + boolean finalizerRemoved, R updatedCustomResource, - RuntimeException runtimeException) { - this.onlyFinalizerHandled = onlyFinalizerHandled; + boolean updateIsStatusPatch, + Exception runtimeException) { + this.finalizerRemoved = finalizerRemoved; this.updatedCustomResource = updatedCustomResource; + this.updateIsStatusPatch = updateIsStatusPatch; this.runtimeException = runtimeException; } - public static PostExecutionControl onlyFinalizerAdded() { - return new PostExecutionControl<>(true, null, null); + public static PostExecutionControl onlyFinalizerAdded( + R updatedCustomResource) { + return new PostExecutionControl<>(false, updatedCustomResource, false, null); } public static PostExecutionControl defaultDispatch() { - return new PostExecutionControl<>(false, null, null); + return new PostExecutionControl<>(false, null, false, null); } - public static PostExecutionControl customResourceUpdated( + public static PostExecutionControl customResourceStatusPatched( R updatedCustomResource) { - return new PostExecutionControl<>(false, updatedCustomResource, null); + return new PostExecutionControl<>(false, updatedCustomResource, true, null); } - public static PostExecutionControl exceptionDuringExecution( - RuntimeException exception) { - return new PostExecutionControl<>(false, null, exception); + public static PostExecutionControl customResourcePatched( + R updatedCustomResource) { + return new PostExecutionControl<>(false, updatedCustomResource, false, null); } - public boolean isOnlyFinalizerHandled() { - return onlyFinalizerHandled; + public static PostExecutionControl customResourceFinalizerRemoved( + R updatedCustomResource) { + return new PostExecutionControl<>(true, updatedCustomResource, false, null); } - public Optional getUpdatedCustomResource() { - return Optional.ofNullable(updatedCustomResource); + public static PostExecutionControl exceptionDuringExecution( + Exception exception) { + return new PostExecutionControl<>(false, null, false, exception); } - public boolean customResourceUpdatedDuringExecution() { - return updatedCustomResource != null; + public Optional getUpdatedCustomResource() { + return Optional.ofNullable(updatedCustomResource); } public boolean exceptionDuringExecution() { @@ -60,7 +66,7 @@ public PostExecutionControl withReSchedule(long delay) { return this; } - public Optional getRuntimeException() { + public Optional getRuntimeException() { return Optional.ofNullable(runtimeException); } @@ -68,15 +74,23 @@ public Optional getReScheduleDelay() { return Optional.ofNullable(reScheduleDelay); } + public boolean updateIsStatusPatch() { + return updateIsStatusPatch; + } + @Override public String toString() { return "PostExecutionControl{" + "onlyFinalizerHandled=" - + 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 index 02c8f8cbb3..90bdc93979 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java @@ -1,240 +1,273 @@ 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.client.CustomResource; +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.javaoperatorsdk.operator.api.ObservedGenerationAware; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; +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.ErrorStatusHandler; -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 static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getName; -import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID; -import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; +import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.*; + +/** Handles calls and results of a Reconciler and finalizer related logic */ +class ReconciliationDispatcher

        { -/** - * 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; + 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) { + 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())); + public ReconciliationDispatcher(Controller

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

        handleExecution(ExecutionScope

        executionScope) { try { return handleDispatch(executionScope); - } catch (KubernetesClientException e) { - log.info( - "Kubernetes exception {} {} during event processing, {} failed", - e.getCode(), - e.getMessage(), - executionScope); - return PostExecutionControl.exceptionDuringExecution(e); - } catch (RuntimeException e) { - log.error("Error during event processing {} failed.", executionScope, e); + } catch (Exception e) { return PostExecutionControl.exceptionDuringExecution(e); } } - private PostExecutionControl handleDispatch(ExecutionScope executionScope) { - R resource = executionScope.getResource(); - log.debug("Handling dispatch for resource {}", getName(resource)); + 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 = resource.isMarkedForDeletion(); - if (markedForDeletion && shouldNotDispatchToDelete(resource)) { + final var markedForDeletion = originalResource.isMarkedForDeletion(); + if (markedForDeletion && shouldNotDispatchToCleanupWhenMarkedForDeletion(originalResource)) { log.debug( - "Skipping delete of resource {} because finalizer(s) {} don't allow processing yet", - getName(resource), - resource.getMetadata().getFinalizers()); + "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, resource); + Context

        context = + new DefaultContext<>(executionScope.getRetryInfo(), controller, resourceForExecution); if (markedForDeletion) { - return handleCleanup(resource, context); + return handleCleanup(resourceForExecution, originalResource, context); } else { - return handleReconcile(executionScope, resource, context); + return handleReconcile(executionScope, resourceForExecution, originalResource, context); } } - /** - * Determines whether the given resource should be dispatched to the controller's - * {@link Reconciler#cleanup(HasMetadata, Context)} method - * - * @param resource the resource to be potentially deleted - * @return {@code true} if the resource should be handed to the controller's - * {@link Reconciler#cleanup(HasMetadata, Context)} method, {@code false} otherwise - */ - private boolean shouldNotDispatchToDelete(R resource) { - // we don't dispatch to delete if the controller is configured to use a finalizer but that - // finalizer is not present (which means it's already been removed) - return !configuration().useFinalizer() || (configuration().useFinalizer() - && !resource.hasFinalizer(configuration().getFinalizer())); + private boolean shouldNotDispatchToCleanupWhenMarkedForDeletion(P resource) { + var alreadyRemovedFinalizer = + controller.useFinalizer() && !resource.hasFinalizer(configuration().getFinalizerName()); + return !controller.useFinalizer() || alreadyRemovedFinalizer; } - private PostExecutionControl handleReconcile( - ExecutionScope executionScope, R originalResource, Context context) { - if (configuration().useFinalizer() - && !originalResource.hasFinalizer(configuration().getFinalizer())) { + 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. */ - updateCustomResourceWithFinalizer(originalResource); - return PostExecutionControl.onlyFinalizerAdded(); + P updatedResource; + if (useSSA) { + updatedResource = addFinalizerWithSSA(originalResource); + } else { + updatedResource = updateCustomResourceWithFinalizer(resourceForExecution, originalResource); + } + return PostExecutionControl.onlyFinalizerAdded(updatedResource); } else { try { - var resourceForExecution = - cloneResourceForErrorStatusHandlerIfNeeded(originalResource, context); return reconcileExecution(executionScope, resourceForExecution, originalResource, context); - } catch (RuntimeException e) { - handleErrorStatusHandler(originalResource, context, e); - throw e; + } catch (Exception e) { + return handleErrorStatusHandler(resourceForExecution, originalResource, context, e); } } } - /** - * Resource make sense only to clone for the ErrorStatusHandler or if the observed generation in - * status is handled automatically. Otherwise, this operation can be skipped since it can be - * memory and time-consuming. However, it needs to be cloned since it's common that the custom - * resource is changed during an execution, and it's much cleaner to have to original resource in - * place for status update. - */ - private R cloneResourceForErrorStatusHandlerIfNeeded(R resource, Context context) { - if (isErrorStatusHandlerPresent() || - shouldUpdateObservedGenerationAutomatically(resource)) { - final var configurationService = configuration().getConfigurationService(); - return configurationService != null ? configurationService.getResourceCloner().clone(resource) - : ConfigurationService.DEFAULT_CLONER.clone(resource); - } else { - return resource; - } + private P cloneResource(P resource) { + return cloner.clone(resource); } - private PostExecutionControl reconcileExecution(ExecutionScope executionScope, - R resourceForExecution, R originalResource, Context context) { + 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); - R updatedCustomResource = null; - if (updateControl.isUpdateResourceAndStatus()) { - updatedCustomResource = updateCustomResource(updateControl.getResource()); - updateControl - .getResource() - .getMetadata() - .setResourceVersion(updatedCustomResource.getMetadata().getResourceVersion()); - updatedCustomResource = updateStatusGenerationAware(updateControl.getResource()); - } else if (updateControl.isUpdateStatus()) { - updatedCustomResource = updateStatusGenerationAware(updateControl.getResource()); - } else if (updateControl.isUpdateResource()) { - updatedCustomResource = updateCustomResource(updateControl.getResource()); - if (shouldUpdateObservedGenerationAutomatically(updatedCustomResource)) { - updatedCustomResource = updateStatusGenerationAware(originalResource); + 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 if (updateControl.isNoUpdate() - && shouldUpdateObservedGenerationAutomatically(resourceForExecution)) { - updatedCustomResource = updateStatusGenerationAware(originalResource); + } else { + toUpdate = + updateControl.isNoUpdate() ? originalResource : updateControl.getResource().orElseThrow(); } - return createPostExecutionControl(updatedCustomResource, updateControl); - } - private void handleErrorStatusHandler(R resource, Context context, - RuntimeException e) { - if (isErrorStatusHandlerPresent()) { - try { - var retryInfo = context.getRetryInfo().orElse(new RetryInfo() { - @Override - public int getAttemptCount() { - return 0; - } - - @Override - public boolean isLastAttempt() { - return controller.getConfiguration().getRetryConfiguration() == null; - } - }); - var updatedResource = ((ErrorStatusHandler) controller.getReconciler()) - .updateErrorStatus(resource, retryInfo, e); - updatedResource.ifPresent(customResourceFacade::updateStatus); - } catch (RuntimeException ex) { - log.error("Error during error status handling.", ex); + if (updateControl.isPatchResource()) { + updatedCustomResource = patchResource(toUpdate, originalResource); + if (!useSSA) { + toUpdate + .getMetadata() + .setResourceVersion(updatedCustomResource.getMetadata().getResourceVersion()); } } - } - private boolean isErrorStatusHandlerPresent() { - return controller.getReconciler() instanceof ErrorStatusHandler; + if (updateControl.isPatchStatus()) { + customResourceFacade.patchStatus(toUpdate, originalResource); + } + return createPostExecutionControl(updatedCustomResource, updateControl); } - private R updateStatusGenerationAware(R resource) { - updateStatusObservedGenerationIfRequired(resource); - return customResourceFacade.updateStatus(resource); - } + 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; + } - private boolean shouldUpdateObservedGenerationAutomatically(R resource) { - if (configuration().isGenerationAware() && resource instanceof CustomResource) { - var customResource = (CustomResource) resource; - var status = customResource.getStatus(); - // Note that if status is null we won't update the observed generation. - if (status instanceof ObservedGenerationAware) { - var observedGen = ((ObservedGenerationAware) status).getObservedGeneration(); - var currentGen = resource.getMetadata().getGeneration(); - return !currentGen.equals(observedGen); + 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); } } - return false; - } - - private void updateStatusObservedGenerationIfRequired(R resource) { - if (configuration().isGenerationAware() && resource instanceof CustomResource) { - var customResource = (CustomResource) resource; - var status = customResource.getStatus(); - // Note that if status is null we won't update the observed generation. - if (status instanceof ObservedGenerationAware) { - ((ObservedGenerationAware) status) - .setObservedGeneration(resource.getMetadata().getGeneration()); + 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(R updatedCustomResource, - UpdateControl updateControl) { - PostExecutionControl postExecutionControl; + private PostExecutionControl

        createPostExecutionControl( + P updatedCustomResource, UpdateControl

        updateControl) { + PostExecutionControl

        postExecutionControl; if (updatedCustomResource != null) { - postExecutionControl = PostExecutionControl.customResourceUpdated(updatedCustomResource); + postExecutionControl = + PostExecutionControl.customResourceStatusPatched(updatedCustomResource); } else { postExecutionControl = PostExecutionControl.defaultDispatch(); } @@ -243,100 +276,268 @@ private PostExecutionControl createPostExecutionControl(R updatedCustomResour } private void updatePostExecutionControlWithReschedule( - PostExecutionControl postExecutionControl, - BaseControl baseControl) { - baseControl.getScheduleDelay().ifPresentOrElse(postExecutionControl::withReSchedule, - () -> controller.getConfiguration().reconciliationMaxInterval() - .ifPresent(m -> postExecutionControl.withReSchedule(m.toMillis()))); + PostExecutionControl

        postExecutionControl, BaseControl baseControl) { + baseControl.getScheduleDelay().ifPresent(postExecutionControl::withReSchedule); } - - private PostExecutionControl handleCleanup(R resource, Context context) { - log.debug( - "Executing delete for resource: {} with version: {}", - getName(resource), - getVersion(resource)); - - DeleteControl deleteControl = controller.cleanup(resource, context); - final var useFinalizer = configuration().useFinalizer(); + 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 done - if (deleteControl.isRemoveFinalizer() - && resource.hasFinalizer(configuration().getFinalizer())) { - R customResource = removeFinalizer(resource); - return PostExecutionControl.customResourceUpdated(customResource); + // 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(resource), - getVersion(resource), + "Skipping finalizer remove for resource: {} with version: {}. delete control: {}, uses" + + " finalizer: {}", + getUID(resourceForExecution), + getVersion(resourceForExecution), deleteControl, useFinalizer); - PostExecutionControl postExecutionControl = PostExecutionControl.defaultDispatch(); + PostExecutionControl

        postExecutionControl = PostExecutionControl.defaultDispatch(); updatePostExecutionControlWithReschedule(postExecutionControl, deleteControl); return postExecutionControl; } - private void updateCustomResourceWithFinalizer(R resource) { + @SuppressWarnings("unchecked") + private P addFinalizerWithSSA(P originalResource) { log.debug( - "Adding finalizer for resource: {} version: {}", getUID(resource), getVersion(resource)); - resource.addFinalizer(configuration().getFinalizer()); - replace(resource); + "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 R updateCustomResource(R resource) { - log.debug("Updating resource: {} with version: {}", getUID(resource), getVersion(resource)); - log.trace("Resource before update: {}", resource); - return replace(resource); + 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 R removeFinalizer(R resource) { + private P patchResource(P resource, P originalResource) { log.debug( - "Removing finalizer on resource: {} with version: {}", + "Updating resource: {} with version: {}; SSA: {}", getUID(resource), - getVersion(resource)); - resource.removeFinalizer(configuration().getFinalizer()); - return customResourceFacade.replaceWithLock(resource); - } + getVersion(resource), + useSSA); + log.trace("Resource before update: {}", resource); - private R replace(R resource) { - log.debug( - "Trying to replace resource {}, version: {}", - getName(resource), - resource.getMetadata().getResourceVersion()); - return customResourceFacade.replaceWithLock(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); } - private ControllerConfiguration configuration() { + 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) { + 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 updateStatus(R resource) { - log.trace("Updating status for resource: {}", resource); - return resourceOperation - .inNamespace(resource.getMetadata().getNamespace()) - .withName(getName(resource)) - .updateStatus(resource); + public R patchResourceWithSSA(R resource) { + return resource(resource) + .patch( + new PatchContext.Builder() + .withFieldManager(fieldManager) + .withForce(true) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build()); } - public R replaceWithLock(R resource) { - return resourceOperation - .inNamespace(resource.getMetadata().getNamespace()) - .withName(getName(resource)) - .lockResourceVersion(resource.getMetadata().getResourceVersion()) - .replace(resource); + 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 index 27634f4b76..5354cad09e 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java @@ -5,22 +5,18 @@ 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()); + return new ResourceID(resource.getMetadata().getName(), resource.getMetadata().getNamespace()); } - public static Optional fromFirstOwnerReference(HasMetadata resource) { - var ownerReferences = resource.getMetadata().getOwnerReferences(); - if (!ownerReferences.isEmpty()) { - return Optional.of(new ResourceID(ownerReferences.get(0).getName(), - resource.getMetadata().getNamespace())); - } else { - return Optional.empty(); - } + public static ResourceID fromOwnerReference( + HasMetadata resource, OwnerReference ownerReference, boolean clusterScoped) { + return new ResourceID( + ownerReference.getName(), clusterScoped ? null : resource.getMetadata().getNamespace()); } private final String name; @@ -45,13 +41,16 @@ public Optional getNamespace() { @Override public boolean equals(Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; + 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); + 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 @@ -61,9 +60,14 @@ public int hashCode() { @Override public String toString() { - return "CustomResourceID{" + - "name='" + name + '\'' + - ", namespace='" + namespace + '\'' + - '}'; + 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 index 5fa45e0a25..fc27e79124 100644 --- 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 @@ -1,12 +1,40 @@ 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 { +public abstract class AbstractEventSource implements EventSource { - private volatile EventHandler handler; + 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; @@ -30,4 +58,36 @@ public void start() throws OperatorException { 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/AbstractResourceEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/AbstractResourceEventSource.java deleted file mode 100644 index de3340eff5..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/AbstractResourceEventSource.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source; - -import io.fabric8.kubernetes.api.model.HasMetadata; - -public abstract class AbstractResourceEventSource

        - extends AbstractEventSource - implements ResourceEventSource { - private final Class resourceClass; - - protected AbstractResourceEventSource(Class resourceClass) { - this.resourceClass = resourceClass; - } - - @Override - public Class getResourceClass() { - return resourceClass; - } -} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/AssociatedSecondaryResourceIdentifier.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/AssociatedSecondaryResourceIdentifier.java deleted file mode 100644 index b402429baa..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/AssociatedSecondaryResourceIdentifier.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source; - -import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.processing.event.ResourceID; - -@FunctionalInterface -public interface AssociatedSecondaryResourceIdentifier

        { - ResourceID associatedSecondaryID(P primary); -} 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/CachingEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/CachingEventSource.java deleted file mode 100644 index 486ffb81a6..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/CachingEventSource.java +++ /dev/null @@ -1,125 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source; - -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Predicate; -import java.util.stream.Stream; - -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; - -/** - * Base class for event sources with caching capabilities. - *

        - * {@link #handleDelete(ResourceID)} - if the related resource is present in the cache it is removed - * and event propagated. There is no event propagated if the resource is not in the cache. - *

        - * {@link #handleEvent(Object, ResourceID)} - caches the resource if changed or missing. Propagates - * an event if the resource is new or not equals to the one in the cache, and if accepted by the - * filter if one is present. - * - * @param represents the type of resources (usually external non-kubernetes ones) being handled. - */ -public abstract class CachingEventSource - extends AbstractResourceEventSource implements Cache { - - protected UpdatableCache cache; - - public CachingEventSource(Class resourceClass) { - super(resourceClass); - cache = initCache(); - } - - @Override - public Optional get(ResourceID resourceID) { - return cache.get(resourceID); - } - - @Override - public boolean contains(ResourceID resourceID) { - return cache.contains(resourceID); - } - - @Override - public Stream keys() { - return cache.keys(); - } - - @Override - public Stream list(Predicate predicate) { - return cache.list(predicate); - } - - protected void handleDelete(ResourceID relatedResourceID) { - if (!isRunning()) { - return; - } - var cachedValue = cache.get(relatedResourceID); - cache.remove(relatedResourceID); - // we only propagate event if the resource was previously in cache - if (cachedValue.isPresent()) { - getEventHandler().handleEvent(new Event(relatedResourceID)); - } - } - - protected void handleEvent(T value, ResourceID relatedResourceID) { - if (!isRunning()) { - return; - } - var cachedValue = cache.get(relatedResourceID); - if (cachedValue.map(v -> !v.equals(value)).orElse(true)) { - cache.put(relatedResourceID, value); - getEventHandler().handleEvent(new Event(relatedResourceID)); - } - } - - protected UpdatableCache initCache() { - return new MapCache<>(); - } - - public Optional getCachedValue(ResourceID resourceID) { - return cache.get(resourceID); - } - - @Override - public void stop() throws OperatorException { - super.stop(); - } - - @Override - public Optional getAssociated(P primary) { - return cache.get(ResourceID.fromResource(primary)); - } - - protected static class MapCache implements UpdatableCache { - private final Map cache = new ConcurrentHashMap<>(); - - @Override - public Optional get(ResourceID resourceID) { - return Optional.ofNullable(cache.get(resourceID)); - } - - @Override - public Stream keys() { - return cache.keySet().stream(); - } - - @Override - public Stream list(Predicate predicate) { - return cache.values().stream().filter(predicate); - } - - @Override - public T remove(ResourceID key) { - return cache.remove(key); - } - - @Override - public void put(ResourceID key, T resource) { - cache.put(key, resource); - } - } -} 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 index 04e19f7ceb..cefe35f6dd 100644 --- 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 @@ -1,32 +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 would not normally trigger your reconciler (as the primary resources are - * not changed). To register EventSources with so that your reconciler is triggered, please make - * your reconciler implement - * {@link io.javaoperatorsdk.operator.api.reconciler.EventSourceInitializer}. + * 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 { +public interface EventSource + extends LifecycleAware, EventSourceHealthIndicator { - /** - * An optional name for your EventSource. This is only required if you need to register multiple - * EventSources for the same resource type (e.g. {@code Deployment}). - * - * @return the name associated with this EventSource - */ - default String name() { - return getClass().getCanonicalName(); + 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/PrimaryResourcesRetriever.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/PrimaryResourcesRetriever.java deleted file mode 100644 index 8f01a95bb3..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/PrimaryResourcesRetriever.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source; - -import java.util.Set; - -import io.javaoperatorsdk.operator.processing.event.ResourceID; - -@FunctionalInterface -public interface PrimaryResourcesRetriever { - - Set associatedPrimaryResources(T dependentResource); -} 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 index dcb15a4229..9ff14d83e0 100644 --- 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 @@ -9,5 +9,4 @@ 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/ResourceEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventSource.java deleted file mode 100644 index ccc907bb0c..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventSource.java +++ /dev/null @@ -1,12 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source; - -import java.util.Optional; - -import io.fabric8.kubernetes.api.model.HasMetadata; - -public interface ResourceEventSource

        extends EventSource { - - Class getResourceClass(); - - Optional getAssociated(P primary); -} 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/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/ControllerResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceCache.java deleted file mode 100644 index ddc7a7658f..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceCache.java +++ /dev/null @@ -1,82 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source.controller; - -import java.util.Map; -import java.util.Optional; -import java.util.function.Predicate; -import java.util.stream.Stream; - -import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.client.informers.SharedIndexInformer; -import io.javaoperatorsdk.operator.OperatorException; -import io.javaoperatorsdk.operator.api.config.Cloner; -import io.javaoperatorsdk.operator.processing.event.ResourceID; -import io.javaoperatorsdk.operator.processing.event.source.Cache; -import io.javaoperatorsdk.operator.processing.event.source.ResourceCache; -import io.javaoperatorsdk.operator.processing.event.source.informer.Mappers; - -import static io.javaoperatorsdk.operator.processing.event.source.controller.ControllerResourceEventSource.ANY_NAMESPACE_MAP_KEY; - -public class ControllerResourceCache implements ResourceCache { - - private final Map> sharedIndexInformers; - private final Cloner cloner; - - public ControllerResourceCache(Map> sharedIndexInformers, - Cloner cloner) { - this.sharedIndexInformers = sharedIndexInformers; - this.cloner = cloner; - } - - @Override - public Stream list(Predicate predicate) { - return sharedIndexInformers.values().stream() - .flatMap(i -> i.getStore().list().stream().filter(predicate)); - } - - @Override - public Stream list(String namespace, Predicate predicate) { - if (isWatchingAllNamespaces()) { - final var stream = sharedIndexInformers.get(ANY_NAMESPACE_MAP_KEY).getStore().list().stream() - .filter(r -> r.getMetadata().getNamespace().equals(namespace)); - return predicate != null ? stream.filter(predicate) : stream; - } else { - final var informer = sharedIndexInformers.get(namespace); - return informer != null ? informer.getStore().list().stream().filter(predicate) - : Stream.empty(); - } - } - - @Override - public Optional get(ResourceID resourceID) { - var sharedIndexInformer = sharedIndexInformers.get(ANY_NAMESPACE_MAP_KEY); - if (sharedIndexInformer == null) { - sharedIndexInformer = - sharedIndexInformers.get(resourceID.getNamespace().orElse(ANY_NAMESPACE_MAP_KEY)); - } - if (sharedIndexInformer == null) { - throw new OperatorException( - "Cannot find informer for ResourceID: " + resourceID + ". This is usually " + - "due to invalid resource id mapping for registered informers."); - } - var resource = sharedIndexInformer.getStore() - .getByKey(io.fabric8.kubernetes.client.informers.cache.Cache.namespaceKeyFunc( - resourceID.getNamespace().orElse(null), - resourceID.getName())); - if (resource == null) { - return Optional.empty(); - } else { - return Optional.of(cloner.clone(resource)); - } - } - - @Override - public Stream keys() { - return sharedIndexInformers.values().stream() - .flatMap(i -> i.getStore().listKeys().stream().map(Mappers::fromString)); - } - - private boolean isWatchingAllNamespaces() { - return sharedIndexInformers.containsKey(ANY_NAMESPACE_MAP_KEY); - } - -} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSource.java deleted file mode 100644 index 9feabf40bf..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSource.java +++ /dev/null @@ -1,209 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source.controller; - -import java.util.Collections; -import java.util.Map; -import java.util.Objects; -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.fabric8.kubernetes.api.model.KubernetesResourceList; -import io.fabric8.kubernetes.client.KubernetesClientException; -import io.fabric8.kubernetes.client.dsl.FilterWatchListDeletable; -import io.fabric8.kubernetes.client.informers.ResourceEventHandler; -import io.fabric8.kubernetes.client.informers.SharedIndexInformer; -import io.javaoperatorsdk.operator.MissingCRDException; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; -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.AbstractResourceEventSource; - -import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getName; -import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID; -import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; - -public class ControllerResourceEventSource - extends AbstractResourceEventSource - implements ResourceEventHandler { - - public static final String ANY_NAMESPACE_MAP_KEY = "anyNamespace"; - - private static final Logger log = LoggerFactory.getLogger(ControllerResourceEventSource.class); - - private final Controller controller; - private final Map> sharedIndexInformers = - new ConcurrentHashMap<>(); - - private final ResourceEventFilter filter; - private final OnceWhitelistEventFilterEventFilter onceWhitelistEventFilterEventFilter; - private final ControllerResourceCache cache; - - public ControllerResourceEventSource(Controller controller) { - super(controller.getConfiguration().getResourceClass()); - this.controller = controller; - final var configurationService = controller.getConfiguration().getConfigurationService(); - var cloner = configurationService != null ? configurationService.getResourceCloner() - : ConfigurationService.DEFAULT_CLONER; - this.cache = new ControllerResourceCache<>(sharedIndexInformers, cloner); - - var filters = new ResourceEventFilter[] { - ResourceEventFilters.finalizerNeededAndApplied(), - ResourceEventFilters.markedForDeletion(), - ResourceEventFilters.generationAware(), - null - }; - - if (controller.getConfiguration().isGenerationAware()) { - onceWhitelistEventFilterEventFilter = new OnceWhitelistEventFilterEventFilter<>(); - filters[filters.length - 1] = onceWhitelistEventFilterEventFilter; - } else { - onceWhitelistEventFilterEventFilter = null; - } - if (controller.getConfiguration().getEventFilter() != null) { - filter = controller.getConfiguration().getEventFilter().and(ResourceEventFilters.or(filters)); - } else { - filter = ResourceEventFilters.or(filters); - } - } - - @Override - public void start() { - final var configuration = controller.getConfiguration(); - final var targetNamespaces = configuration.getEffectiveNamespaces(); - final var client = controller.getCRClient(); - final var labelSelector = configuration.getLabelSelector(); - - try { - if (ControllerConfiguration.allNamespacesWatched(targetNamespaces)) { - final var informer = - createAndRunInformerFor(client.inAnyNamespace() - .withLabelSelector(labelSelector), ANY_NAMESPACE_MAP_KEY); - log.debug("Registered {} -> {} for any namespace", controller, informer); - } else { - targetNamespaces.forEach(ns -> { - final var informer = createAndRunInformerFor( - client.inNamespace(ns).withLabelSelector(labelSelector), ns); - log.debug("Registered {} -> {} for namespace: {}", controller, informer, ns); - }); - } - } catch (Exception e) { - if (e instanceof KubernetesClientException) { - handleKubernetesClientException(e); - } - throw e; - } - super.start(); - } - - private SharedIndexInformer createAndRunInformerFor( - FilterWatchListDeletable> filteredBySelectorClient, String key) { - var informer = filteredBySelectorClient.runnableInformer(0); - informer.addEventHandler(this); - sharedIndexInformers.put(key, informer); - informer.run(); - return informer; - } - - @Override - public void stop() { - for (SharedIndexInformer informer : sharedIndexInformers.values()) { - try { - log.info("Stopping informer {} -> {}", controller, informer); - informer.stop(); - } catch (Exception e) { - log.warn("Error stopping informer {} -> {}", controller, informer, e); - } - } - super.stop(); - } - - public void eventReceived(ResourceAction action, T customResource, T oldResource) { - try { - log.debug( - "Event received for resource: {}", getName(customResource)); - MDCUtils.addResourceInfo(customResource); - controller.getEventSourceManager().broadcastOnResourceEvent(action, customResource, - oldResource); - if (filter.acceptChange(controller.getConfiguration(), oldResource, customResource)) { - getEventHandler().handleEvent( - new ResourceEvent(action, ResourceID.fromResource(customResource))); - } else { - log.debug( - "Skipping event handling resource {} with version: {}", - getUID(customResource), - getVersion(customResource)); - } - } finally { - MDCUtils.removeResourceInfo(); - } - } - - @Override - public void onAdd(T resource) { - eventReceived(ResourceAction.ADDED, resource, null); - } - - @Override - public void onUpdate(T oldCustomResource, T newCustomResource) { - eventReceived(ResourceAction.UPDATED, newCustomResource, oldCustomResource); - } - - @Override - public void onDelete(T resource, boolean b) { - eventReceived(ResourceAction.DELETED, resource, null); - } - - public Optional get(ResourceID resourceID) { - return cache.get(resourceID); - } - - public ControllerResourceCache getResourceCache() { - return cache; - } - - /** - * @return shared informers by namespace. If custom resource is not namespace scoped use - * CustomResourceEventSource.ANY_NAMESPACE_MAP_KEY - */ - public Map> getInformers() { - return Collections.unmodifiableMap(sharedIndexInformers); - } - - public SharedIndexInformer getInformer(String namespace) { - return getInformers().get(Objects.requireNonNullElse(namespace, ANY_NAMESPACE_MAP_KEY)); - } - - /** - * This will ensure that the next event received after this method is called will not be filtered - * out. - * - * @param resourceID - to which the event is related - */ - public void whitelistNextEvent(ResourceID resourceID) { - if (onceWhitelistEventFilterEventFilter != null) { - onceWhitelistEventFilterEventFilter.whitelistNextEvent(resourceID); - } - } - - - private void handleKubernetesClientException(Exception e) { - KubernetesClientException ke = (KubernetesClientException) e; - if (404 == ke.getCode()) { - // only throw MissingCRDException if the 404 error occurs on the target CRD - final var targetCRDName = controller.getConfiguration().getResourceTypeName(); - if (targetCRDName.equals(ke.getFullResourceName())) { - throw new MissingCRDException(targetCRDName, null, e.getMessage(), e); - } - } - } - - @Override - public Optional getAssociated(T primary) { - return cache.get(ResourceID.fromResource(primary)); - } -} 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/OnceWhitelistEventFilterEventFilter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/OnceWhitelistEventFilterEventFilter.java deleted file mode 100644 index 8262ff1c21..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/OnceWhitelistEventFilterEventFilter.java +++ /dev/null @@ -1,35 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source.controller; - -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; -import io.javaoperatorsdk.operator.processing.event.ResourceID; - -public class OnceWhitelistEventFilterEventFilter - implements ResourceEventFilter { - - private static final Logger log = - LoggerFactory.getLogger(OnceWhitelistEventFilterEventFilter.class); - - private final ConcurrentMap whiteList = new ConcurrentHashMap<>(); - - @Override - public boolean acceptChange(ControllerConfiguration configuration, T oldResource, - T newResource) { - ResourceID resourceID = ResourceID.fromResource(newResource); - boolean res = whiteList.remove(resourceID, resourceID); - if (res) { - log.debug("Accepting whitelisted event for CR id: {}", resourceID); - } - return res; - } - - public void whitelistNextEvent(ResourceID resourceID) { - whiteList.putIfAbsent(resourceID, resourceID); - } -} 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 index 7a04dc9164..d1dbcb9e1b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceAction.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceAction.java @@ -1,5 +1,7 @@ package io.javaoperatorsdk.operator.processing.event.source.controller; public enum ResourceAction { - ADDED, UPDATED, DELETED + 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 index 15424fadb4..f97cedf7f5 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEvent.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEvent.java @@ -1,28 +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) { + 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() + - '}'; + 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/controller/ResourceEventFilter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEventFilter.java deleted file mode 100644 index 497c9016b7..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEventFilter.java +++ /dev/null @@ -1,58 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source.controller; - -import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; - -/** - * A functional interface to determine whether resource events should be processed by the SDK. This - * allows users to more finely tuned which events trigger a reconciliation than was previously - * possible (where the logic was limited to generation-based checking). - * - * @param the type of custom resources handled by this filter - */ -@FunctionalInterface -public interface ResourceEventFilter { - - /** - * Determines whether the change between the old version of the resource and the new one needs to - * be propagated to the controller or not. - * - * @param configuration the target controller's configuration - * @param oldResource the old version of the resource, null if no old resource available - * @param newResource the new version of the resource - * @return {@code true} if the change needs to be propagated to the controller, {@code false} - * otherwise - */ - boolean acceptChange(ControllerConfiguration configuration, T oldResource, T newResource); - - /** - * Combines this filter with the provided one with an AND logic, i.e. the resulting filter will - * only accept the change if both this and the other filter accept it, reject it otherwise. - * - * @param other the possibly {@code null} other filter to combine this one with - * @return a composite filter implementing the AND logic between this and the provided filter - */ - default ResourceEventFilter and(ResourceEventFilter other) { - return other == null ? this - : (ControllerConfiguration configuration, T oldResource, T newResource) -> { - boolean result = acceptChange(configuration, oldResource, newResource); - return result && other.acceptChange(configuration, oldResource, newResource); - }; - } - - /** - * Combines this filter with the provided one with an OR logic, i.e. the resulting filter will - * accept the change if any of this or the other filter accept it, rejecting it only if both - * reject it. - * - * @param other the possibly {@code null} other filter to combine this one with - * @return a composite filter implementing the OR logic between this and the provided filter - */ - default ResourceEventFilter or(ResourceEventFilter other) { - return other == null ? this - : (ControllerConfiguration configuration, T oldResource, T newResource) -> { - boolean result = acceptChange(configuration, oldResource, newResource); - return result || other.acceptChange(configuration, oldResource, newResource); - }; - } -} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEventFilters.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEventFilters.java deleted file mode 100644 index c314af7f2e..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ResourceEventFilters.java +++ /dev/null @@ -1,165 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source.controller; - -import io.fabric8.kubernetes.api.model.HasMetadata; - -/** - * Convenience implementations of, and utility methods for, {@link ResourceEventFilter}. - */ -public final class ResourceEventFilters { - - private static final ResourceEventFilter USE_FINALIZER = - (configuration, oldResource, newResource) -> { - if (configuration.useFinalizer()) { - final var finalizer = configuration.getFinalizer(); - boolean oldFinalizer = oldResource == null || oldResource.hasFinalizer(finalizer); - boolean newFinalizer = newResource.hasFinalizer(finalizer); - - return !newFinalizer || !oldFinalizer; - } else { - return false; - } - }; - - private static final ResourceEventFilter GENERATION_AWARE = - (configuration, oldResource, newResource) -> { - final var generationAware = configuration.isGenerationAware(); - return oldResource == null || !generationAware || - oldResource.getMetadata().getGeneration() < newResource.getMetadata().getGeneration(); - }; - - private static final ResourceEventFilter PASSTHROUGH = - (configuration, oldResource, newResource) -> true; - - private static final ResourceEventFilter NONE = - (configuration, oldResource, newResource) -> false; - - private static final ResourceEventFilter MARKED_FOR_DELETION = - (configuration, oldResource, newResource) -> newResource.isMarkedForDeletion(); - - private ResourceEventFilters() {} - - /** - * Retrieves a filter that accepts all events. - * - * @param the type of custom resource the filter should handle - * @return a filter that accepts all events - */ - @SuppressWarnings("unchecked") - public static ResourceEventFilter passthrough() { - return (ResourceEventFilter) PASSTHROUGH; - } - - /** - * Retrieves a filter that reject all events. - * - * @param the type of custom resource the filter should handle - * @return a filter that reject all events - */ - @SuppressWarnings("unchecked") - public static ResourceEventFilter none() { - return (ResourceEventFilter) NONE; - } - - /** - * Retrieves a filter that accepts all events if generation-aware processing is not activated but - * only changes that represent a generation increase otherwise. - * - * @param the type of custom resource the filter should handle - * @return a filter accepting changes based on generation information - */ - @SuppressWarnings("unchecked") - public static ResourceEventFilter generationAware() { - return (ResourceEventFilter) GENERATION_AWARE; - } - - /** - * Retrieves a filter that accepts changes if the target controller uses a finalizer and that - * finalizer hasn't already been applied, rejecting them otherwise. - * - * @param the type of custom resource the filter should handle - * @return a filter accepting changes based on whether the finalizer is needed and has been - * applied - */ - @SuppressWarnings("unchecked") - public static ResourceEventFilter finalizerNeededAndApplied() { - return (ResourceEventFilter) USE_FINALIZER; - } - - /** - * Retrieves a filter that accepts changes if the custom resource is marked for deletion. - * - * @param the type of custom resource the filter should handle - * @return a filter accepting changes based on whether the Custom Resource is marked for deletion. - */ - @SuppressWarnings("unchecked") - public static ResourceEventFilter markedForDeletion() { - return (ResourceEventFilter) MARKED_FOR_DELETION; - } - - /** - * Combines the provided, potentially {@code null} filters with an AND logic, i.e. the resulting - * filter will only accept the change if all filters accept it, reject it otherwise. - *

        - * Note that the evaluation of filters is lazy: the result is returned as soon as possible without - * evaluating all filters if possible. - * - * @param items the filters to combine - * @param the type of custom resources the filters are supposed to handle - * @return a combined filter implementing the AND logic combination of the provided filters - */ - @SafeVarargs - public static ResourceEventFilter and( - ResourceEventFilter... items) { - if (items == null) { - return none(); - } - - return (configuration, oldResource, newResource) -> { - for (ResourceEventFilter item : items) { - if (item == null) { - continue; - } - - if (!item.acceptChange(configuration, oldResource, newResource)) { - return false; - } - } - - return true; - }; - } - - /** - * Combines the provided, potentially {@code null} filters with an OR logic, i.e. the resulting - * filter will accept the change if any of the filters accepts it, rejecting it only if all reject - * it. - *

        - * Note that the evaluation of filters is lazy: the result is returned as soon as possible without - * evaluating all filters if possible. - * - * @param items the filters to combine - * @param the type of custom resources the filters are supposed to handle - * @return a combined filter implementing the OR logic combination of both provided filters - */ - @SafeVarargs - public static ResourceEventFilter or( - ResourceEventFilter... items) { - if (items == null) { - return none(); - } - - return (configuration, oldResource, newResource) -> { - for (ResourceEventFilter item : items) { - if (item == null) { - continue; - } - - if (item.acceptChange(configuration, oldResource, newResource)) { - return true; - } - } - - return false; - }; - } -} 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 index 1d60da2a3a..6c3ebf6916 100644 --- 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 @@ -1,20 +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.CachingEventSource; +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 class CachingInboundEventSource extends CachingEventSource { + 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); + } - public CachingInboundEventSource(Class resourceClass) { - super(resourceClass); + private Set getAndCacheResource(P primary) { + var primaryID = ResourceID.fromResource(primary); + var values = resourceFetcher.fetchResources(primary); + handleResources(primaryID, values, false); + fetchedForPrimaries.add(primaryID); + return values; } - public void handleResourceEvent(T resource, ResourceID relatedResourceID) { - super.handleEvent(resource, relatedResourceID); + /** + * 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 void handleResourceDeleteEvent(ResourceID resourceID) { - super.handleDelete(resourceID); + 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 index a441684f0f..7d5f2aa446 100644 --- 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 @@ -1,16 +1,27 @@ 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 { +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)); @@ -19,4 +30,8 @@ public void propagateEvent(ResourceID 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 index eada096713..c029a54170 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -1,181 +1,343 @@ package io.javaoperatorsdk.operator.processing.event.source.informer; -import java.util.Objects; import java.util.Optional; -import java.util.function.Predicate; -import java.util.stream.Stream; +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.fabric8.kubernetes.client.informers.SharedInformer; -import io.fabric8.kubernetes.client.informers.cache.Store; +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.AbstractResourceEventSource; -import io.javaoperatorsdk.operator.processing.event.source.AssociatedSecondaryResourceIdentifier; -import io.javaoperatorsdk.operator.processing.event.source.PrimaryResourcesRetriever; -import io.javaoperatorsdk.operator.processing.event.source.ResourceCache; +import io.javaoperatorsdk.operator.processing.event.source.PrimaryToSecondaryMapper; -public class InformerEventSource - extends AbstractResourceEventSource - implements ResourceCache { +/** + * 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(); - private final SharedInformer sharedInformer; - private final PrimaryResourcesRetriever secondaryToPrimaryResourcesIdSet; - private final AssociatedSecondaryResourceIdentifier

        associatedWith; - private final boolean skipUpdateEventPropagationIfNoChange; - - public InformerEventSource(SharedInformer sharedInformer, - PrimaryResourcesRetriever resourceToTargetResourceIDSet) { - this(sharedInformer, resourceToTargetResourceIDSet, null, true); - } - - public InformerEventSource(KubernetesClient client, Class type, - PrimaryResourcesRetriever resourceToTargetResourceIDSet) { - this(client, type, resourceToTargetResourceIDSet, false); - } - - public InformerEventSource(KubernetesClient client, Class type, - PrimaryResourcesRetriever resourceToTargetResourceIDSet, - AssociatedSecondaryResourceIdentifier

        associatedWith, - boolean skipUpdateEventPropagationIfNoChange) { - this(client.informers().sharedIndexInformerFor(type, 0), resourceToTargetResourceIDSet, - associatedWith, - skipUpdateEventPropagationIfNoChange); - } - - InformerEventSource(KubernetesClient client, Class type, - PrimaryResourcesRetriever resourceToTargetResourceIDSet, - boolean skipUpdateEventPropagationIfNoChange) { - this(client.informers().sharedIndexInformerFor(type, 0), resourceToTargetResourceIDSet, null, - skipUpdateEventPropagationIfNoChange); - } - - public InformerEventSource(SharedInformer sharedInformer, - PrimaryResourcesRetriever resourceToTargetResourceIDSet, - AssociatedSecondaryResourceIdentifier

        associatedWith, - boolean skipUpdateEventPropagationIfNoChange) { - super(sharedInformer.getApiTypeClass()); - this.sharedInformer = sharedInformer; - this.secondaryToPrimaryResourcesIdSet = resourceToTargetResourceIDSet; - this.skipUpdateEventPropagationIfNoChange = skipUpdateEventPropagationIfNoChange; - if (sharedInformer.isRunning()) { - log.warn( - "Informer is already running on event source creation, this is not desirable and may " + - "lead to non deterministic behavior."); + 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(); } - this.associatedWith = - Objects.requireNonNullElseGet(associatedWith, () -> ResourceID::fromResource); + final var informerConfig = configuration.getInformerConfig(); + onAddFilter = informerConfig.getOnAddFilter(); + onUpdateFilter = informerConfig.getOnUpdateFilter(); + onDeleteFilter = informerConfig.getOnDeleteFilter(); + genericFilter = informerConfig.getGenericFilter(); + } - sharedInformer.addEventHandler(new ResourceEventHandler<>() { - @Override - public void onAdd(T t) { - propagateEvent(t); - } + @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(T oldObject, T newObject) { - if (newObject == null) { - // this is a fix for this potential issue with informer: - // https://github.com/java-operator-sdk/java-operator-sdk/issues/830 - propagateEvent(oldObject); - return; - } + @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)); + } - if (InformerEventSource.this.skipUpdateEventPropagationIfNoChange && - oldObject.getMetadata().getResourceVersion() - .equals(newObject.getMetadata().getResourceVersion())) { - return; - } + @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; + } - @Override - public void onDelete(T t, boolean b) { - propagateEvent(t); + 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(T object) { - var primaryResourceIdSet = secondaryToPrimaryResourcesIdSet.associatedPrimaryResources(object); + 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); - } - }); + 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 void start() { - sharedInformer.run(); + 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 void stop() { - sharedInformer.close(); + public synchronized void handleRecentResourceUpdate( + ResourceID resourceID, R resource, R previousVersionOfResource) { + handleRecentCreateOrUpdate(Operation.UPDATE, resource, previousVersionOfResource); } - private Store getStore() { - return sharedInformer.getStore(); + @Override + public synchronized void handleRecentResourceCreate(ResourceID resourceID, R resource) { + handleRecentCreateOrUpdate(Operation.ADD, resource, null); } - /** - * Retrieves the informed resource associated with the specified primary resource as defined by - * the function provided when this InformerEventSource was created - * - * @param resource the primary resource we want to retrieve the associated resource for - * @return the informed resource associated with the specified primary resource - */ - public Optional getAssociated(P resource) { - final var id = associatedWith.associatedSecondaryID(resource); - return get(id); + 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)); } - - public SharedInformer getSharedInformer() { - return sharedInformer; + private boolean useSecondaryToPrimaryIndex() { + return this.primaryToSecondaryMapper == null; } @Override - public Optional get(ResourceID resourceID) { - return Optional.ofNullable(sharedInformer.getStore() - .getByKey(io.fabric8.kubernetes.client.informers.cache.Cache.namespaceKeyFunc( - resourceID.getNamespace().orElse(null), - resourceID.getName()))); + public boolean allowsNamespaceChanges() { + return configuration().followControllerNamespaceChanges(); } - @Override - public Stream list(Predicate predicate) { - return getStore().list().stream().filter(predicate); + 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); + } } - @Override - public Stream list(String namespace, Predicate predicate) { - return getStore().list().stream() - .filter(v -> namespace.equals(v.getMetadata().getNamespace()) && predicate.test(v)); + private boolean acceptedByDeleteFilters(R resource, boolean b) { + return (onDeleteFilter == null || onDeleteFilter.accept(resource, b)) + && (genericFilter == null || genericFilter.accept(resource)); } - @Override - public Stream keys() { - return getStore().listKeys().stream().map(Mappers::fromString); + /** + * 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..2bb6dcbc75 --- /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 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 index 053528e0ff..7ed46a97f3 100644 --- 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 @@ -2,40 +2,101 @@ 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.PrimaryResourcesRetriever; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; public class Mappers { - public static PrimaryResourcesRetriever fromAnnotation( - String nameKey) { - return fromMetadata(nameKey, null, false); + 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); } - public static PrimaryResourcesRetriever fromAnnotation( - String nameKey, String namespaceKey) { - return fromMetadata(nameKey, namespaceKey, false); + @SuppressWarnings("unused") + public static SecondaryToPrimaryMapper fromLabel( + String nameKey, + String namespaceKey, + String typeKey, + Class primaryResourceType) { + return fromMetadata(nameKey, namespaceKey, typeKey, primaryResourceType, true); } - public static PrimaryResourcesRetriever fromLabel( - String nameKey) { - return fromMetadata(nameKey, null, true); + public static SecondaryToPrimaryMapper fromOwnerReferences( + Class primaryResourceType) { + return fromOwnerReferences(primaryResourceType, false); } - public static PrimaryResourcesRetriever fromLabel( - String nameKey, String namespaceKey) { - return fromMetadata(nameKey, namespaceKey, true); + public static SecondaryToPrimaryMapper fromOwnerReferences( + Class primaryResourceType, boolean clusterScoped) { + return fromOwnerReferences( + HasMetadata.getApiVersion(primaryResourceType), + HasMetadata.getKind(primaryResourceType), + clusterScoped); } - public static PrimaryResourcesRetriever fromOwnerReference() { - return resource -> ResourceID.fromFirstOwnerReference(resource).map(Set::of) - .orElse(Collections.emptySet()); + public static SecondaryToPrimaryMapper fromOwnerReferences( + HasMetadata primaryResource) { + return fromOwnerReferences(primaryResource, false); } - private static PrimaryResourcesRetriever fromMetadata( - String nameKey, String namespaceKey, boolean isLabel) { + 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) { @@ -51,6 +112,15 @@ private static PrimaryResourcesRetriever fromMetadata } 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)); } }; @@ -62,13 +132,48 @@ public static ResourceID fromString(String cacheKey) { } final String[] split = cacheKey.split("/"); - switch (split.length) { - case 1: - return new ResourceID(split[0]); - case 2: - return new ResourceID(split[1], split[0]); - default: - throw new IllegalArgumentException("Cannot extract a ResourceID from " + cacheKey); + 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 index 9130d27a45..b6f6cd79cd 100644 --- 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 @@ -1,10 +1,15 @@ 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.Timer; -import java.util.TimerTask; +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; @@ -12,114 +17,128 @@ 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.CachingEventSource; +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 CachingEventSource} * - * @param the resource polled by the event source - * @param related custom resource + *

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

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

        { private static final Logger log = LoggerFactory.getLogger(PerResourcePollingEventSource.class); - private final Timer timer = new Timer(); - private final Map timerTasks = new ConcurrentHashMap<>(); - private final ResourceSupplier resourceSupplier; - private final Cache resourceCache; - private final Predicate registerPredicate; - private final long period; - - public PerResourcePollingEventSource(ResourceSupplier resourceSupplier, - Cache resourceCache, long period, Class resourceClass) { - this(resourceSupplier, resourceCache, period, null, resourceClass); + 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(); } - public PerResourcePollingEventSource(ResourceSupplier resourceSupplier, - Cache resourceCache, long period, - Predicate registerPredicate, Class resourceClass) { - super(resourceClass); - this.resourceSupplier = resourceSupplier; - this.resourceCache = resourceCache; - this.period = period; - this.registerPredicate = registerPredicate; + 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; } - private void pollForResource(R resource) { - var value = resourceSupplier.getResource(resource); - var resourceID = ResourceID.fromResource(resource); - if (value.isEmpty()) { - super.handleDelete(resourceID); - } else { - super.handleEvent(value.get(), resourceID); - } - } - - private Optional getAndCacheResource(ResourceID resourceID) { - var resource = resourceCache.get(resourceID); - if (resource.isPresent()) { - var value = resourceSupplier.getResource(resource.get()); - value.ifPresent(v -> cache.put(resourceID, v)); - return value; - } - return Optional.empty(); + @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(R resource) { + public void onResourceCreated(P resource) { checkAndRegisterTask(resource); } @Override - public void onResourceUpdated(R newResource, R oldResource) { + public void onResourceUpdated(P newResource, P oldResource) { checkAndRegisterTask(newResource); } @Override - public void onResourceDeleted(R resource) { + public void onResourceDeleted(P resource) { var resourceID = ResourceID.fromResource(resource); - TimerTask task = timerTasks.remove(resourceID); - if (task != null) { - log.debug("Canceling task for resource: {}", resource); - task.cancel(); + var scheduledFuture = scheduledFutures.remove(resourceID); + if (scheduledFuture != null) { + log.debug("Canceling scheduledFuture for resource: {}", resource); + scheduledFuture.cancel(true); } - cache.remove(resourceID); + 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(R resource) { - var resourceID = ResourceID.fromResource(resource); - if (timerTasks.get(resourceID) == null && (registerPredicate == null - || registerPredicate.test(resource))) { - var task = new TimerTask() { - @Override - public void run() { - if (!isRunning()) { - log.debug("Event source not yet started. Will not run for: {}", resourceID); - return; - } - // always use up-to-date resource from cache - var res = resourceCache.get(resourceID); - res.ifPresentOrElse(r -> pollForResource(r), - () -> log.warn("No resource in cache for resource ID: {}", resourceID)); - } - }; - timerTasks.put(resourceID, task); - timer.schedule(task, 0, period); + // 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)); + } } } @@ -131,33 +150,44 @@ public void run() { * @return the related resource for this event source */ @Override - public Optional getAssociated(R primary) { - return getValueFromCacheOrSupplier(ResourceID.fromResource(primary)); - } - - /** - * - * @param resourceID of the target related resource - * @return the cached value of the resource, if not present it gets the resource from the - * supplier. The value provided from the supplier is cached, but no new event is - * propagated. - */ - public Optional getValueFromCacheOrSupplier(ResourceID resourceID) { - var cachedValue = getCachedValue(resourceID); - if (cachedValue.isPresent()) { - return cachedValue; + 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 { - return getAndCacheResource(resourceID); + if (fetchedForPrimaries.contains(primaryID)) { + return Collections.emptySet(); + } else { + return getAndCacheResource(primary, true); + } } } - public interface ResourceSupplier { - Optional getResource(R resource); + 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(); - timer.cancel(); + 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 index 4702bd8c38..fe7c9ce391 100644 --- 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 @@ -1,84 +1,96 @@ package io.javaoperatorsdk.operator.processing.event.source.polling; -import java.util.*; -import java.util.function.Supplier; +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.CachingEventSource; +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 of state of custom - * resources managed by the operator. It is called on start (synced). This means that when the - * reconciler first time executed on startup a 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 + * 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 #put(ResourceID, Object)} method. - *

        - * So the generic workflow in reconciler should be: + * 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.
        • + *
        • 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 type of the polled resource * @param

        primary resource type */ -public class PollingEventSource extends CachingEventSource { +public class PollingEventSource + extends ExternalResourceCachingEventSource { private static final Logger log = LoggerFactory.getLogger(PollingEventSource.class); private final Timer timer = new Timer(); - private final Supplier> supplierToPoll; - private final long period; + private final GenericResourceFetcher genericResourceFetcher; + private final Duration period; + private final AtomicBoolean healthy = new AtomicBoolean(true); - public PollingEventSource(Supplier> supplier, - long period, Class resourceClass) { - super(resourceClass); - this.supplierToPoll = supplier; - this.period = period; + 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() { - if (!isRunning()) { - log.debug("Event source not yet started. Will not run."); - return; - } - getStateAndFillCache(); - } - }, period, period); + 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 void getStateAndFillCache() { - var values = supplierToPoll.get(); - values.forEach((k, v) -> super.handleEvent(v, k)); - cache.keys().filter(e -> !values.containsKey(e)).forEach(super::handleDelete); + protected synchronized void getStateAndFillCache() { + var values = genericResourceFetcher.fetchResources(); + handleResources(values); } - public void put(ResourceID key, T resource) { - cache.put(key, resource); + public interface GenericResourceFetcher { + Map> fetchResources(); } @Override @@ -87,15 +99,8 @@ public void stop() throws OperatorException { timer.cancel(); } - /** - * See {@link PerResourcePollingEventSource} for more info. - * - * @param primary custom resource - * @return related resource - */ @Override - public Optional getAssociated(P primary) { - return getCachedValue(ResourceID.fromResource(primary)); + 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 index cadebc3deb..a6801b5f39 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java @@ -1,40 +1,51 @@ 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 java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.health.Status; 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 +public class TimerEventSource extends AbstractEventSource implements ResourceEventAware { private static final Logger log = LoggerFactory.getLogger(TimerEventSource.class); - private final Timer timer = new Timer(); - private final AtomicBoolean running = new AtomicBoolean(); + 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) { - if (!running.get()) { + scheduleOnce(ResourceID.fromResource(resource), delay); + } + + public void scheduleOnce(ResourceID resourceID, long delay) { + if (!isRunning()) { throw new IllegalStateException("The TimerEventSource is not running"); } - ResourceID resourceUid = ResourceID.fromResource(resource); - if (onceTasks.containsKey(resourceUid)) { - cancelOnceSchedule(resourceUid); + + if (onceTasks.containsKey(resourceID)) { + cancelOnceSchedule(resourceID); } - EventProducerTimeTask task = new EventProducerTimeTask(resourceUid); - onceTasks.put(resourceUid, task); + EventProducerTimeTask task = new EventProducerTimeTask(resourceID); + onceTasks.put(resourceID, task); timer.schedule(task, delay); } @@ -52,14 +63,29 @@ public void cancelOnceSchedule(ResourceID customResourceUid) { @Override public void start() { - running.set(true); + if (!isRunning()) { + super.start(); + timer = new Timer(true); + } } @Override public void stop() { - running.set(false); - onceTasks.keySet().forEach(this::cancelOnceSchedule); - timer.cancel(); + if (isRunning()) { + onceTasks.keySet().forEach(this::cancelOnceSchedule); + timer.cancel(); + super.stop(); + } + } + + @Override + public Status getStatus() { + return isRunning() ? Status.HEALTHY : Status.UNHEALTHY; + } + + @Override + public Set getSecondaryResources(HasMetadata primary) { + return Set.of(); } public class EventProducerTimeTask extends TimerTask { @@ -72,7 +98,7 @@ public EventProducerTimeTask(ResourceID customResourceUid) { @Override public void run() { - if (running.get()) { + 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 index bed8e43c9d..a8e1c5b466 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java @@ -1,15 +1,17 @@ package io.javaoperatorsdk.operator.processing.retry; -import io.javaoperatorsdk.operator.api.config.RetryConfiguration; +import io.javaoperatorsdk.operator.api.config.AnnotationConfigurable; -public class GenericRetry implements Retry { - private int maxAttempts = DEFAULT_MAX_ATTEMPTS; - private long initialInterval = DEFAULT_INITIAL_INTERVAL; - private double intervalMultiplier = DEFAULT_MULTIPLIER; - private long maxInterval = -1; +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 new GenericRetry(); + return (GenericRetry) DEFAULT; } public static GenericRetry noRetry() { @@ -20,15 +22,6 @@ public static GenericRetry every10second10TimesRetry() { return new GenericRetry().withLinearRetry().setMaxAttempts(10).setInitialInterval(10000); } - public static Retry fromConfiguration(RetryConfiguration configuration) { - return configuration == null ? defaultLimitedExponentialRetry() - : new GenericRetry() - .setInitialInterval(configuration.getInitialInterval()) - .setMaxAttempts(configuration.getMaxAttempts()) - .setIntervalMultiplier(configuration.getIntervalMultiplier()) - .setMaxInterval(configuration.getMaxInterval()); - } - @Override public GenericRetryExecution initExecution() { return new GenericRetryExecution(this); @@ -83,4 +76,15 @@ 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 index 32bf154f97..a2c7a9a609 100644 --- 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 @@ -15,8 +15,7 @@ public GenericRetryExecution(GenericRetry genericRetry) { } public Optional nextDelay() { - if (genericRetry.getMaxAttempts() > -1 - && lastAttemptIndex >= genericRetry.getMaxAttempts()) { + if (genericRetry.getMaxAttempts() > -1 && lastAttemptIndex >= genericRetry.getMaxAttempts()) { return Optional.empty(); } if (lastAttemptIndex > 1) { @@ -31,8 +30,7 @@ public Optional nextDelay() { @Override public boolean isLastAttempt() { - return genericRetry.getMaxAttempts() > -1 - && lastAttemptIndex >= genericRetry.getMaxAttempts(); + return genericRetry.getMaxAttempts() > -1 && lastAttemptIndex >= genericRetry.getMaxAttempts(); } @Override 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 index b7911d1a74..fb36aaa92c 100644 --- 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 @@ -1,8 +1,7 @@ package io.javaoperatorsdk.operator.processing.retry; -import io.javaoperatorsdk.operator.api.config.RetryConfiguration; - -public interface Retry extends RetryConfiguration { +@FunctionalInterface +public interface Retry { RetryExecution initExecution(); } 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 index c970855f84..f6b2837554 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ControllerManagerTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ControllerManagerTest.java @@ -3,75 +3,59 @@ import org.junit.jupiter.api.Test; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.client.CustomResource; -import io.javaoperatorsdk.operator.Operator.ControllerManager; -import io.javaoperatorsdk.operator.api.config.DefaultControllerConfiguration; +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.DuplicateCRController; import io.javaoperatorsdk.operator.sample.simple.TestCustomReconciler; -import io.javaoperatorsdk.operator.sample.simple.TestCustomReconcilerOtherV1; import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; -import io.javaoperatorsdk.operator.sample.simple.TestCustomResourceOtherV1; +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; -public class ControllerManagerTest { +class ControllerManagerTest { @Test - public void shouldNotAddMultipleControllersForSameCustomResource() { - final var registered = new TestControllerConfiguration<>(new TestCustomReconciler(null), - TestCustomResource.class); - final var duplicated = - new TestControllerConfiguration<>(new DuplicateCRController(), TestCustomResource.class); - - checkException(registered, duplicated); - } - - @Test - public void addingMultipleControllersForCustomResourcesWithSameVersionsShouldNotWork() { - final var registered = new TestControllerConfiguration<>(new TestCustomReconciler(null), - TestCustomResource.class); - final var duplicated = new TestControllerConfiguration<>(new TestCustomReconcilerOtherV1(), - TestCustomResourceOtherV1.class); - - checkException(registered, duplicated); - } - - private void checkException( - TestControllerConfiguration registered, - TestControllerConfiguration duplicated) { - final var exception = assertThrows(OperatorException.class, () -> { - final var controllerManager = new ControllerManager(); - controllerManager.add(new Controller<>(registered.controller, registered, null)); - controllerManager.add(new Controller<>(duplicated.controller, duplicated, null)); - }); - final var msg = exception.getMessage(); + 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( - msg.contains("Cannot register controller '" + duplicated.getControllerName() + "'") - && msg.contains(registered.getControllerName()) - && msg.contains(registered.getResourceTypeName())); + ex.getMessage().contains(CANNOT_REGISTER_MULTIPLE_CONTROLLERS_WITH_SAME_NAME_MESSAGE)); } private static class TestControllerConfiguration - extends DefaultControllerConfiguration { - private final Reconciler controller; - - public TestControllerConfiguration(Reconciler controller, Class crClass) { - super(null, getControllerName(controller), - CustomResource.getCRDName(crClass), null, false, null, null, null, null, crClass, - null, null); - this.controller = controller; + 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) { + static String getControllerName(Reconciler controller) { return controller.getClass().getSimpleName() + "Controller"; } - - private String getControllerName() { - return getControllerName(controller); - } } } 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 index c234a0b9b2..39fc98f6b0 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/OperatorTest.java @@ -1,75 +1,86 @@ package io.javaoperatorsdk.operator; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.DisplayName; +import java.util.function.Consumer; + import org.junit.jupiter.api.Test; -import io.fabric8.kubernetes.client.CustomResource; +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.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.config.RetryConfiguration; +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.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.*; +@SuppressWarnings("rawtypes") class OperatorTest { - - private final KubernetesClient kubernetesClient = mock(KubernetesClient.class); - private final ConfigurationService configurationService = mock(ConfigurationService.class); - private final ControllerConfiguration configuration = mock(ControllerConfiguration.class); - private final Operator operator = new Operator(kubernetesClient, configurationService); - private final FooReconciler fooReconciler = FooReconciler.create(); - @Test - @DisplayName("should register `Reconciler` to Controller") - public void shouldRegisterReconcilerToController() { - // given - when(configurationService.getConfigurationFor(fooReconciler)).thenReturn(configuration); - when(configuration.watchAllNamespaces()).thenReturn(true); - when(configuration.getName()).thenReturn("FOO"); - when(configuration.getResourceClass()).thenReturn(FooCustomResource.class); - when(configuration.getRetryConfiguration()).thenReturn(RetryConfiguration.DEFAULT); - - // when - operator.register(fooReconciler); - - // then - assertThat(operator.getControllers().size()).isEqualTo(1); - assertThat(operator.getControllers().get(0).getReconciler()).isEqualTo(fooReconciler); - } + void shouldBePossibleToRetrieveNumberOfRegisteredControllers() { + final var operator = new Operator(); + assertEquals(0, operator.getRegisteredControllersNumber()); - @Test - @DisplayName("should throw `OperationException` when Configuration is null") - public void shouldThrowOperatorExceptionWhenConfigurationIsNull() { - Assertions.assertThrows(OperatorException.class, () -> operator.register(fooReconciler, null)); + operator.register(new FooReconciler()); + assertEquals(1, operator.getRegisteredControllersNumber()); } - private static class FooCustomResource extends CustomResource { + @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()); } - private static class FooSpec { + @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 FooStatus { + private static class FooReconciler implements Reconciler { + @Override + public UpdateControl reconcile(ConfigMap resource, Context context) { + return UpdateControl.noUpdate(); + } } - private static class FooReconciler implements Reconciler { + private static class OperatorExtension extends Operator { + public OperatorExtension() {} - private FooReconciler() {} - - public static FooReconciler create() { - return new FooReconciler(); + 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 - public UpdateControl reconcile(FooCustomResource resource, Context context) { - return UpdateControl.noUpdate(); + 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 index d46936b3d5..ad77196068 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java @@ -1,21 +1,40 @@ package io.javaoperatorsdk.operator; +import java.net.URI; + import org.junit.jupiter.api.Test; -import io.fabric8.kubernetes.api.model.Pod; -import io.javaoperatorsdk.operator.api.reconciler.Constants; +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.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +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( @@ -36,7 +55,181 @@ void defaultFinalizerShouldWork() { } @Test - void noFinalizerMarkerShouldWork() { - assertTrue(isFinalizerValid(Constants.NO_FINALIZER)); + 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 index 137ed2d11d..afef9e6703 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/TestUtils.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/TestUtils.java @@ -3,6 +3,7 @@ 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; @@ -27,6 +28,10 @@ public static CustomResourceDefinition testCRD(String scope) { .build(); } + public static TestCustomResource testCustomResource1() { + return testCustomResource(new ResourceID("test1", "default")); + } + public static TestCustomResource testCustomResource(ResourceID id) { TestCustomResource resource = new TestCustomResource(); resource.setMetadata( @@ -44,5 +49,8 @@ public static TestCustomResource testCustomResource(ResourceID id) { 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 index 9907c0405e..66137ed790 100644 --- 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 @@ -9,8 +9,7 @@ class DeleteControlTest { @Test void cannotReScheduleForDefaultDelete() { - Assertions.assertThrows(IllegalStateException.class, - () -> DeleteControl.defaultDelete().rescheduleAfter(1000L)); + 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/ControllerConfigurationTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationTest.java deleted file mode 100644 index 3bc72d81f2..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationTest.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.javaoperatorsdk.operator.api.config; - -import org.junit.jupiter.api.Test; - -import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; - -import static org.junit.jupiter.api.Assertions.*; - -class ControllerConfigurationTest { - - @Test - void getCustomResourceClass() { - final ControllerConfiguration conf = new ControllerConfiguration<>() { - @Override - public String getAssociatedReconcilerClassName() { - return null; - } - - @Override - public ConfigurationService getConfigurationService() { - return null; - } - }; - assertEquals(TestCustomResource.class, conf.getResourceClass()); - } -} 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 index d61b50d583..82ecdb111a 100644 --- 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 @@ -1,38 +1,49 @@ 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.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.V1ApiextensionAPIGroupDSL; -import io.fabric8.kubernetes.client.dsl.ApiextensionsAPIGroupDSL; -import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; -import io.fabric8.kubernetes.client.dsl.Resource; -import io.javaoperatorsdk.operator.MissingCRDException; +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.ControllerConfiguration; +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 org.junit.jupiter.api.Assertions.assertThrows; +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 = mock(KubernetesClient.class); - final var configurationService = mock(ConfigurationService.class); - final var reconciler = mock(Reconciler.class); - final var configuration = mock(ControllerConfiguration.class); - when(configuration.getResourceClass()).thenReturn(Secret.class); - when(configuration.getConfigurationService()).thenReturn(configurationService); - + 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(); @@ -40,40 +51,86 @@ void crdShouldNotBeCheckedForNativeResources() { @Test void crdShouldNotBeCheckedForCustomResourcesIfDisabled() { - final var client = mock(KubernetesClient.class); - final var configurationService = mock(ConfigurationService.class); - when(configurationService.checkCRDAndValidateLocalModel()).thenReturn(false); - final var reconciler = mock(Reconciler.class); - final var configuration = mock(ControllerConfiguration.class); - when(configuration.getResourceClass()).thenReturn(TestCustomResource.class); - when(configuration.getConfigurationService()).thenReturn(configurationService); + 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 crdShouldBeCheckedForCustomResourcesByDefault() { - final var client = mock(KubernetesClient.class); - final var configurationService = mock(ConfigurationService.class); - when(configurationService.checkCRDAndValidateLocalModel()).thenCallRealMethod(); - final var reconciler = mock(Reconciler.class); - final var configuration = mock(ControllerConfiguration.class); - when(configuration.getResourceClass()).thenReturn(TestCustomResource.class); + 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); - 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)); + 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, client); - // since we're not really connected to a cluster and the CRD wouldn't be deployed anyway, we - // expect a MissingCRDException to be thrown - assertThrows(MissingCRDException.class, controller::start); - verify(client, times(1)).apiextensions(); + 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/EventMarkerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventMarkerTest.java deleted file mode 100644 index 7f808874a8..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventMarkerTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class EventMarkerTest { - - private final EventMarker eventMarker = new EventMarker(); - private ResourceID sampleResourceID = new ResourceID("test-name"); - private ResourceID sampleResourceID2 = new ResourceID("test-name2"); - - @Test - public void returnsNoEventPresentIfNotMarkedYet() { - assertThat(eventMarker.noEventPresent(sampleResourceID)).isTrue(); - } - - @Test - public void marksEvent() { - eventMarker.markEventReceived(sampleResourceID); - - assertThat(eventMarker.eventPresent(sampleResourceID)).isTrue(); - assertThat(eventMarker.deleteEventPresent(sampleResourceID)).isFalse(); - } - - @Test - public void marksDeleteEvent() { - eventMarker.markDeleteEventReceived(sampleResourceID); - - assertThat(eventMarker.deleteEventPresent(sampleResourceID)) - .isTrue(); - assertThat(eventMarker.eventPresent(sampleResourceID)).isFalse(); - } - - @Test - public void afterDeleteEventMarkEventIsNotRelevant() { - eventMarker.markEventReceived(sampleResourceID); - - eventMarker.markDeleteEventReceived(sampleResourceID); - - assertThat(eventMarker.deleteEventPresent(sampleResourceID)) - .isTrue(); - assertThat(eventMarker.eventPresent(sampleResourceID)).isFalse(); - } - - @Test - public void cleansUp() { - eventMarker.markEventReceived(sampleResourceID); - eventMarker.markDeleteEventReceived(sampleResourceID); - - eventMarker.cleanup(sampleResourceID); - - assertThat(eventMarker.deleteEventPresent(sampleResourceID)).isFalse(); - assertThat(eventMarker.eventPresent(sampleResourceID)).isFalse(); - } - - @Test - public void cannotMarkEventAfterDeleteEventReceived() { - Assertions.assertThrows(IllegalStateException.class, () -> { - eventMarker.markDeleteEventReceived(sampleResourceID); - eventMarker.markEventReceived(sampleResourceID); - }); - } - - @Test - public void listsResourceIDSWithEventsPresent() { - eventMarker.markEventReceived(sampleResourceID); - eventMarker.markEventReceived(sampleResourceID2); - eventMarker.unMarkEventReceived(sampleResourceID); - - var res = eventMarker.resourceIDsWithEventPresent(); - - assertThat(res).hasSize(1); - assertThat(res).contains(sampleResourceID2); - } - -} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java index 5a3f73742e..9819eb7ee9 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java @@ -1,5 +1,6 @@ package io.javaoperatorsdk.operator.processing.event; +import java.time.Duration; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -7,25 +8,41 @@ 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.source.controller.ControllerResourceCache; -import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerResourceEventSource; +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; @@ -34,6 +51,7 @@ 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); @@ -41,49 +59,56 @@ class EventProcessorTest { 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"; - private ReconciliationDispatcher reconciliationDispatcherMock = + 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 EventSourceManager eventSourceManagerMock = mock(EventSourceManager.class); - private ControllerResourceCache resourceCacheMock = - mock(ControllerResourceCache.class); - private TimerEventSource retryTimerEventSourceMock = mock(TimerEventSource.class); - private ControllerResourceEventSource controllerResourceEventSourceMock = - mock(ControllerResourceEventSource.class); - private Metrics metricsMock = mock(Metrics.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 - public void setup() { - - when(eventSourceManagerMock.getControllerResourceEventSource()) - .thenReturn(controllerResourceEventSourceMock); - when(controllerResourceEventSourceMock.getResourceCache()).thenReturn(resourceCacheMock); - + void setup() { + when(eventSourceManagerMock.getControllerEventSource()).thenReturn(controllerEventSourceMock); eventProcessor = - spy(new EventProcessor(reconciliationDispatcherMock, eventSourceManagerMock, "Test", null, - null)); + spy( + new EventProcessor( + controllerConfiguration(null, rateLimiterMock), + reconciliationDispatcherMock, + eventSourceManagerMock, + null)); eventProcessor.start(); eventProcessorWithRetry = - spy(new EventProcessor(reconciliationDispatcherMock, eventSourceManagerMock, "Test", - GenericRetry.defaultLimitedExponentialRetry(), null)); + 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 - public void dispatchesEventsIfNoExecutionInProgress() { + void dispatchesEventsIfNoExecutionInProgress() { eventProcessor.handleEvent(prepareCREvent()); verify(reconciliationDispatcherMock, timeout(50).times(1)).handleExecution(any()); } @Test - public void skipProcessingIfLatestCustomResourceNotInCache() { + void skipProcessingIfLatestCustomResourceNotInCache() { Event event = prepareCREvent(); - when(resourceCacheMock.get(event.getRelatedCustomResourceID())).thenReturn(Optional.empty()); + when(controllerEventSourceMock.get(event.getRelatedCustomResourceID())) + .thenReturn(Optional.empty()); eventProcessor.handleEvent(event); @@ -91,7 +116,7 @@ public void skipProcessingIfLatestCustomResourceNotInCache() { } @Test - public void ifExecutionInProgressWaitsUntilItsFinished() throws InterruptedException { + void ifExecutionInProgressWaitsUntilItsFinished() { ResourceID resourceUid = eventAlreadyUnderProcessing(); eventProcessor.handleEvent(nonCREvent(resourceUid)); @@ -101,21 +126,23 @@ public void ifExecutionInProgressWaitsUntilItsFinished() throws InterruptedExcep } @Test - public void schedulesAnEventRetryOnException() { + void schedulesAnEventRetryOnException() { TestCustomResource customResource = testCustomResource(); - ExecutionScope executionScope = new ExecutionScope(customResource, null); + 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(customResource), eq(GenericRetry.DEFAULT_INITIAL_INTERVAL)); + .scheduleOnce( + eq(ResourceID.fromResource(customResource)), eq(GradualRetry.DEFAULT_INITIAL_INTERVAL)); } @Test - public void executesTheControllerInstantlyAfterErrorIfNewEventsReceived() { + void executesTheControllerInstantlyAfterErrorIfNewEventsReceived() { Event event = prepareCREvent(); TestCustomResource customResource = testCustomResource(); overrideData(event.getRelatedCustomResourceID(), customResource); @@ -123,7 +150,13 @@ public void executesTheControllerInstantlyAfterErrorIfNewEventsReceived() { PostExecutionControl.exceptionDuringExecution(new RuntimeException("test")); when(reconciliationDispatcherMock.handleExecution(any())) - .thenReturn(postExecutionControl) + .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 @@ -138,11 +171,12 @@ public void executesTheControllerInstantlyAfterErrorIfNewEventsReceived() { List allValues = executionScopeArgumentCaptor.getAllValues(); assertThat(allValues).hasSize(2); verify(retryTimerEventSourceMock, never()) - .scheduleOnce(eq(customResource), eq(GenericRetry.DEFAULT_INITIAL_INTERVAL)); + .scheduleOnce( + eq(ResourceID.fromResource(customResource)), eq(GradualRetry.DEFAULT_INITIAL_INTERVAL)); } @Test - public void successfulExecutionResetsTheRetry() { + void successfulExecutionResetsTheRetry() { log.info("Starting successfulExecutionResetsTheRetry"); Event event = prepareCREvent(); @@ -162,14 +196,17 @@ public void successfulExecutionResetsTheRetry() { 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(); @@ -181,8 +218,15 @@ public void successfulExecutionResetsTheRetry() { 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 - public void scheduleTimedEventIfInstructedByPostExecutionControl() { + void scheduleTimedEventIfInstructedByPostExecutionControl() { var testDelay = 10000L; when(reconciliationDispatcherMock.handleExecution(any())) .thenReturn(PostExecutionControl.defaultDispatch().withReSchedule(testDelay)); @@ -190,116 +234,267 @@ public void scheduleTimedEventIfInstructedByPostExecutionControl() { eventProcessor.handleEvent(prepareCREvent()); verify(retryTimerEventSourceMock, timeout(SEPARATE_EXECUTION_TIMEOUT).times(1)) - .scheduleOnce(any(), eq(testDelay)); + .scheduleOnce((ResourceID) any(), eq(testDelay)); } @Test - public void reScheduleOnlyIfNotExecutedEventsReceivedMeanwhile() { + void reScheduleOnlyIfNotExecutedEventsReceivedMeanwhile() throws InterruptedException { var testDelay = 10000L; - when(reconciliationDispatcherMock.handleExecution(any())) - .thenReturn(PostExecutionControl.defaultDispatch().withReSchedule(testDelay)); - - eventProcessor.handleEvent(prepareCREvent()); - eventProcessor.handleEvent(prepareCREvent()); - - verify(retryTimerEventSourceMock, timeout(SEPARATE_EXECUTION_TIMEOUT).times(0)) - .scheduleOnce(any(), eq(testDelay)); + 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 - public void doNotFireEventsIfClosing() { + void doNotFireEventsIfClosing() { eventProcessor.stop(); eventProcessor.handleEvent(prepareCREvent()); - verify(reconciliationDispatcherMock, timeout(50).times(0)).handleExecution(any()); + verify(reconciliationDispatcherMock, after(50).times(0)).handleExecution(any()); } @Test - public void whitelistNextEventIfTheCacheIsNotPropagatedAfterAnUpdate() { + void cancelScheduleOnceEventsOnSuccessfulExecution() { var crID = new ResourceID("test-cr", TEST_NAMESPACE); var cr = testCustomResource(crID); - var updatedCr = testCustomResource(crID); - updatedCr.getMetadata().setResourceVersion("2"); - var mockCREventSource = mock(ControllerResourceEventSource.class); - eventProcessor.getEventMarker().markEventReceived(crID); - when(resourceCacheMock.get(eq(crID))).thenReturn(Optional.of(cr)); - when(eventSourceManagerMock.getControllerResourceEventSource()).thenReturn(mockCREventSource); - eventProcessor.eventProcessingFinished(new ExecutionScope(cr, null), - PostExecutionControl.customResourceUpdated(updatedCr)); + eventProcessor.eventProcessingFinished( + new ExecutionScope(null).setResource(cr), PostExecutionControl.defaultDispatch()); - verify(mockCREventSource, times(1)).whitelistNextEvent(eq(crID)); + verify(retryTimerEventSourceMock, times(1)).cancelOnceSchedule(eq(crID)); } @Test - public void dontWhitelistsEventWhenOtherChangeDuringExecution() { + void skipsGenericEventIfNoResourceEventReceivedBefore() { var crID = new ResourceID("test-cr", TEST_NAMESPACE); - var cr = testCustomResource(crID); - var updatedCr = testCustomResource(crID); - updatedCr.getMetadata().setResourceVersion("2"); - var otherChangeCR = testCustomResource(crID); - otherChangeCR.getMetadata().setResourceVersion("3"); - var mockCREventSource = mock(ControllerResourceEventSource.class); - eventProcessor.getEventMarker().markEventReceived(crID); - when(resourceCacheMock.get(eq(crID))).thenReturn(Optional.of(otherChangeCR)); - when(eventSourceManagerMock.getControllerResourceEventSource()).thenReturn(mockCREventSource); - - eventProcessor.eventProcessingFinished(new ExecutionScope(cr, null), - PostExecutionControl.customResourceUpdated(updatedCr)); - - verify(mockCREventSource, times(0)).whitelistNextEvent(eq(crID)); + 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 - public void dontWhitelistsEventIfUpdatedEventInCache() { + void startProcessedMarkedEventReceivedBefore() { var crID = new ResourceID("test-cr", TEST_NAMESPACE); - var cr = testCustomResource(crID); - var mockCREventSource = mock(ControllerResourceEventSource.class); - eventProcessor.getEventMarker().markEventReceived(crID); - when(resourceCacheMock.get(eq(crID))).thenReturn(Optional.of(cr)); - when(eventSourceManagerMock.getControllerResourceEventSource()).thenReturn(mockCREventSource); + 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())); - eventProcessor.eventProcessingFinished(new ExecutionScope(cr, null), - PostExecutionControl.customResourceUpdated(cr)); + verify(reconciliationDispatcherMock, timeout(100).times(0)).handleExecution(any()); - verify(mockCREventSource, times(0)).whitelistNextEvent(eq(crID)); + eventProcessor.start(); + + verify(reconciliationDispatcherMock, timeout(100).times(1)).handleExecution(any()); + verify(metricsMock, times(1)).reconcileCustomResource(any(HasMetadata.class), isNull(), any()); } @Test - public void cancelScheduleOnceEventsOnSuccessfulExecution() { - var crID = new ResourceID("test-cr", TEST_NAMESPACE); - var cr = testCustomResource(crID); + void notUpdatesEventSourceHandlerIfResourceUpdated() { + TestCustomResource customResource = testCustomResource(); + ExecutionScope executionScope = new ExecutionScope(null).setResource(customResource); + PostExecutionControl postExecutionControl = + PostExecutionControl.customResourceStatusPatched(customResource); - eventProcessor.eventProcessingFinished(new ExecutionScope(cr, null), - PostExecutionControl.defaultDispatch()); + eventProcessorWithRetry.eventProcessingFinished(executionScope, postExecutionControl); - verify(retryTimerEventSourceMock, times(1)).cancelOnceSchedule(eq(crID)); + verify(controllerEventSourceMock, times(0)).handleRecentResourceUpdate(any(), any(), any()); } @Test - public void startProcessedMarkedEventReceivedBefore() { - var crID = new ResourceID("test-cr", TEST_NAMESPACE); - eventProcessor = - spy(new EventProcessor(reconciliationDispatcherMock, eventSourceManagerMock, "Test", null, - metricsMock)); - when(resourceCacheMock.get(eq(crID))).thenReturn(Optional.of(testCustomResource())); - eventProcessor.handleEvent(new Event(crID)); + void notReschedulesAfterTheFinalizerRemoveProcessed() { + TestCustomResource customResource = testCustomResource(); + markForDeletion(customResource); + ExecutionScope executionScope = new ExecutionScope(null).setResource(customResource); + PostExecutionControl postExecutionControl = + PostExecutionControl.customResourceFinalizerRemoved(customResource); - verify(reconciliationDispatcherMock, timeout(100).times(0)).handleExecution(any()); + 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(); - verify(reconciliationDispatcherMock, timeout(100).times(1)).handleExecution(any()); - verify(metricsMock, times(1)).reconcileCustomResource(any(), isNull()); + 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(); - }); + (Answer) + invocationOnMock -> { + Thread.sleep(FAKE_CONTROLLER_EXECUTION_DURATION); + return PostExecutionControl.defaultDispatch(); + }); Event event = prepareCREvent(); eventProcessor.handleEvent(event); return event.getRelatedCustomResourceID(); @@ -309,11 +504,18 @@ private ResourceEvent prepareCREvent() { return prepareCREvent(new ResourceID(UUID.randomUUID().toString(), TEST_NAMESPACE)); } - private ResourceEvent prepareCREvent(ResourceID uid) { - TestCustomResource customResource = testCustomResource(uid); - when(resourceCacheMock.get(eq(uid))).thenReturn(Optional.of(customResource)); - return new ResourceEvent(ResourceAction.UPDATED, - ResourceID.fromResource(customResource)); + 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) { @@ -325,4 +527,18 @@ private void overrideData(ResourceID id, HasMetadata applyTo) { 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 index a330b4130c..7592512cb3 100644 --- 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 @@ -1,54 +1,60 @@ package io.javaoperatorsdk.operator.processing.event; -import java.io.IOException; -import java.util.Iterator; -import java.util.Optional; 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.ControllerConfiguration; +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.CachingEventSource; +import io.javaoperatorsdk.operator.processing.event.source.AbstractEventSource; import io.javaoperatorsdk.operator.processing.event.source.EventSource; -import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerResourceEventSource; +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.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +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 EventProcessor eventHandler = mock(EventProcessor.class); - private final EventSourceManager eventSourceManager = new EventSourceManager(eventHandler); + 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); - Set registeredSources = eventSourceManager.getRegisteredEventSources(); + final var registeredSources = eventSourceManager.getRegisteredEventSources(); assertThat(registeredSources).contains(eventSource); - verify(eventSource, times(1)).setEventHandler(eq(eventSourceManager.getEventHandler())); + verify(eventSource, times(1)).setEventHandler(any()); } @Test - public void closeShouldCascadeToEventSources() throws IOException { + 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); @@ -62,7 +68,13 @@ public void closeShouldCascadeToEventSources() throws IOException { @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); @@ -74,105 +86,113 @@ public void startCascadesToEventSources() { @Test void retrievingEventSourceForClassShouldWork() { - assertTrue(eventSourceManager.getResourceEventSourceFor(null).isEmpty()); - assertTrue(eventSourceManager.getResourceEventSourceFor(Class.class).isEmpty()); + assertThatExceptionOfType(NoEventSourceForClassException.class) + .isThrownBy(() -> eventSourceManager.getEventSourceFor(Class.class)); // manager is initialized with a controller configured to handle HasMetadata EventSourceManager manager = initManager(); - Optional source = manager.getResourceEventSourceFor(HasMetadata.class); - assertTrue(source.isPresent()); - assertTrue(source.get() instanceof ControllerResourceEventSource); + assertThatExceptionOfType(NoEventSourceForClassException.class) + .isThrownBy(() -> manager.getEventSourceFor(HasMetadata.class, "unknown_name")); - CachingEventSource eventSource = mock(CachingEventSource.class); - when(eventSource.getResourceClass()).thenReturn(String.class); + ManagedInformerEventSource eventSource = mock(ManagedInformerEventSource.class); + when(eventSource.resourceType()).thenReturn(String.class); + when(eventSource.name()).thenReturn("name1"); manager.registerEventSource(eventSource); - source = manager.getResourceEventSourceFor(String.class); - assertTrue(source.isPresent()); - assertEquals(eventSource, source.get()); + var source = manager.getEventSourceFor(String.class); + assertThat(source).isNotNull(); + assertEquals(eventSource, source); } @Test - void shouldNotBePossibleToAddEventSourcesForSameTypeAndName() { + void notPossibleAddEventSourcesForSameName() { EventSourceManager manager = initManager(); + final var name = "name1"; - CachingEventSource eventSource = mock(CachingEventSource.class); - when(eventSource.getResourceClass()).thenReturn(TestCustomResource.class); - when(eventSource.name()).thenReturn("name1"); + ManagedInformerEventSource eventSource = mock(ManagedInformerEventSource.class); + when(eventSource.name()).thenReturn(name); + when(eventSource.resourceType()).thenReturn(TestCustomResource.class); manager.registerEventSource(eventSource); - eventSource = mock(CachingEventSource.class); - when(eventSource.getResourceClass()).thenReturn(TestCustomResource.class); - when(eventSource.name()).thenReturn("name1"); + 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 exception = + assertThrows(OperatorException.class, () -> manager.registerEventSource(source)); final var cause = exception.getCause(); - assertTrue(cause instanceof IllegalArgumentException); - assertThat(cause.getMessage()).contains( - "An event source is already registered for the (io.javaoperatorsdk.operator.sample.simple.TestCustomResource, name1) class/name combination"); + assertInstanceOf(IllegalArgumentException.class, cause); + assertThat(cause.getMessage()).contains("is already registered with name"); } @Test void retrievingAnEventSourceWhenMultipleAreRegisteredForATypeShouldRequireAQualifier() { EventSourceManager manager = initManager(); - CachingEventSource eventSource = mock(CachingEventSource.class); - when(eventSource.getResourceClass()).thenReturn(TestCustomResource.class); + ManagedInformerEventSource eventSource = mock(ManagedInformerEventSource.class); + when(eventSource.resourceType()).thenReturn(TestCustomResource.class); when(eventSource.name()).thenReturn("name1"); manager.registerEventSource(eventSource); - CachingEventSource eventSource2 = mock(CachingEventSource.class); - when(eventSource2.getResourceClass()).thenReturn(TestCustomResource.class); + 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.getResourceEventSourceFor(TestCustomResource.class)); + final var exception = + assertThrows( + IllegalArgumentException.class, + () -> manager.getEventSourceFor(TestCustomResource.class)); assertTrue(exception.getMessage().contains("name1")); assertTrue(exception.getMessage().contains("name2")); - assertEquals(manager.getResourceEventSourceFor(TestCustomResource.class, "name2").get(), - eventSource2); - assertEquals(manager.getResourceEventSourceFor(TestCustomResource.class, "name1").get(), - eventSource); + assertEquals(manager.getEventSourceFor(TestCustomResource.class, "name2"), eventSource2); + assertEquals(manager.getEventSourceFor(TestCustomResource.class, "name1"), eventSource); } @Test - void timerAndControllerEventSourcesShouldBeListedFirst() { - EventSourceManager manager = initManager(); + void changesNamespacesOnControllerAndInformerEventSources() { + String newNamespaces = "new-namespace"; - CachingEventSource eventSource = mock(CachingEventSource.class); - when(eventSource.getResourceClass()).thenReturn(String.class); - manager.registerEventSource(eventSource); + 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)); - final Set sources = manager.getRegisteredEventSources(); - assertEquals(3, sources.size()); - final Iterator iterator = sources.iterator(); - for (int i = 0; i < sources.size(); i++) { - final EventSource source = iterator.next(); - switch (i) { - case 0: - assertTrue(source instanceof TimerEventSource); - break; - case 1: - assertTrue(source instanceof ControllerResourceEventSource); - break; - case 2: - assertTrue(source instanceof CachingEventSource); - break; - default: - fail(); - } - } + 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 Controller controller = mock(Controller.class); - final ControllerConfiguration configuration = mock(ControllerConfiguration.class); - when(configuration.getResourceClass()).thenReturn(HasMetadata.class); - when(controller.getConfiguration()).thenReturn(configuration); + 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 index 8e4be5e6dc..89f3655356 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java @@ -4,46 +4,50 @@ 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.RetryConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.Constants; +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.ErrorStatusHandler; +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.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.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; -import static org.mockito.Mockito.withSettings; +import static org.mockito.Mockito.*; +@SuppressWarnings({"unchecked", "rawtypes"}) class ReconciliationDispatcherTest { private static final String DEFAULT_FINALIZER = "javaoperatorsdk.io/finalizer"; @@ -51,38 +55,25 @@ class ReconciliationDispatcherTest { public static final long RECONCILIATION_MAX_INTERVAL = 10L; private TestCustomResource testCustomResource; private ReconciliationDispatcher reconciliationDispatcher; - private final Reconciler reconciler = mock(Reconciler.class, - withSettings().extraInterfaces(ErrorStatusHandler.class)); - private final ConfigurationService configService = mock(ConfigurationService.class); + private TestReconciler reconciler; private final CustomResourceFacade customResourceFacade = mock(ReconciliationDispatcher.CustomResourceFacade.class); - private ControllerConfiguration configuration = mock(ControllerConfiguration.class); + private static ConfigurationService configurationService; @BeforeEach void setup() { + initConfigService(true); testCustomResource = TestUtils.testCustomResource(); + reconciler = spy(new TestReconciler()); reconciliationDispatcher = init(testCustomResource, reconciler, null, customResourceFacade, true); } - private ReconciliationDispatcher init(R customResource, - Reconciler reconciler, ControllerConfiguration configuration, - CustomResourceFacade customResourceFacade, boolean useFinalizer) { - - configuration = configuration == null ? mock(ControllerConfiguration.class) : configuration; - ReconciliationDispatcherTest.this.configuration = configuration; - final var finalizer = useFinalizer ? DEFAULT_FINALIZER : Constants.NO_FINALIZER; - when(configuration.getFinalizer()).thenReturn(finalizer); - when(configuration.useFinalizer()).thenCallRealMethod(); - when(configuration.getName()).thenReturn("EventDispatcherTestController"); - when(configuration.getResourceClass()).thenReturn((Class) customResource.getClass()); - when(configuration.getRetryConfiguration()).thenReturn(RetryConfiguration.DEFAULT); - when(configuration.reconciliationMaxInterval()) - .thenReturn(Optional.of(Duration.ofHours(RECONCILIATION_MAX_INTERVAL))); - - when(configuration.getConfigurationService()).thenReturn(configService); - + 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), @@ -90,16 +81,58 @@ private ReconciliationDispatcher init(R customResourc * equals will fail on the two equal but NOT identical TestCustomResources because equals is not * implemented on TestCustomResourceSpec or TestCustomResourceStatus */ - when(configService.getResourceCloner()).thenReturn(new Cloner() { - @Override + 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)); + } - public T clone(T object) { - return object; - } - }); - when(reconciler.cleanup(eq(customResource), any())) - .thenReturn(DeleteControl.defaultDelete()); - Controller controller = new Controller<>(reconciler, configuration, null); + 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); } @@ -108,11 +141,25 @@ public T clone(T object) { void addFinalizerOnNewResource() { assertFalse(testCustomResource.hasFinalizer(DEFAULT_FINALIZER)); reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(reconciler, never()) - .reconcile(ArgumentMatchers.eq(testCustomResource), any()); + verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); verify(customResourceFacade, times(1)) - .replaceWithLock( + .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(); } @@ -120,35 +167,33 @@ void addFinalizerOnNewResource() { void callCreateOrUpdateOnNewResourceIfFinalizerSet() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(reconciler, times(1)) - .reconcile(ArgumentMatchers.eq(testCustomResource), any()); + verify(reconciler, times(1)).reconcile(ArgumentMatchers.eq(testCustomResource), any()); } @Test - void updatesOnlyStatusSubResourceIfFinalizerSet() { + void patchesBothResourceAndStatusIfFinalizerSet() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); - when(reconciler.reconcile(eq(testCustomResource), any())) - .thenReturn(UpdateControl.updateStatus(testCustomResource)); + reconciler.reconcile = (r, c) -> UpdateControl.patchResourceAndStatus(testCustomResource); + when(customResourceFacade.patchResource(eq(testCustomResource), any())) + .thenReturn(testCustomResource); reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(customResourceFacade, times(1)).updateStatus(testCustomResource); - verify(customResourceFacade, never()).replaceWithLock(any()); + verify(customResourceFacade, times(1)).patchResource(eq(testCustomResource), any()); + verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); } @Test - void updatesBothResourceAndStatusIfFinalizerSet() { + void patchesStatus() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); - when(reconciler.reconcile(eq(testCustomResource), any())) - .thenReturn(UpdateControl.updateResourceAndStatus(testCustomResource)); - when(customResourceFacade.replaceWithLock(testCustomResource)).thenReturn(testCustomResource); + reconciler.reconcile = (r, c) -> UpdateControl.patchStatus(testCustomResource); reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(customResourceFacade, times(1)).replaceWithLock(testCustomResource); - verify(customResourceFacade, times(1)).updateStatus(testCustomResource); + verify(customResourceFacade, times(1)).patchStatus(eq(testCustomResource), any()); + verify(customResourceFacade, never()).patchResource(any(), any()); } @Test @@ -156,8 +201,7 @@ void callCreateOrUpdateOnModifiedResourceIfFinalizerSet() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(reconciler, times(1)) - .reconcile(ArgumentMatchers.eq(testCustomResource), any()); + verify(reconciler, times(1)).reconcile(ArgumentMatchers.eq(testCustomResource), any()); } @Test @@ -171,6 +215,90 @@ void callsDeleteIfObjectHasFinalizerAndMarkedForDelete() { 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 = @@ -201,53 +329,43 @@ void doesNotAddFinalizerIfConfiguredNotTo() { assertEquals(0, testCustomResource.getMetadata().getFinalizers().size()); } - @Test - void removesDefaultFinalizerOnDeleteIfSet() { - testCustomResource.addFinalizer(DEFAULT_FINALIZER); - markForDeletion(testCustomResource); - - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - - assertEquals(0, testCustomResource.getMetadata().getFinalizers().size()); - verify(customResourceFacade, times(1)).replaceWithLock(any()); - } - @Test void doesNotRemovesTheSetFinalizerIfTheDeleteNotMethodInstructsIt() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); - when(reconciler.cleanup(eq(testCustomResource), any())) - .thenReturn(DeleteControl.noFinalizerRemoval()); + reconciler.cleanup = (r, c) -> DeleteControl.noFinalizerRemoval(); markForDeletion(testCustomResource); reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); assertEquals(1, testCustomResource.getMetadata().getFinalizers().size()); - verify(customResourceFacade, never()).replaceWithLock(any()); + verify(customResourceFacade, never()).patchResource(any(), any()); } @Test void doesNotUpdateTheResourceIfNoUpdateUpdateControlIfFinalizerSet() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); - when(reconciler.reconcile(eq(testCustomResource), any())) - .thenReturn(UpdateControl.noUpdate()); + reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(customResourceFacade, never()).replaceWithLock(any()); - verify(customResourceFacade, never()).updateStatus(testCustomResource); + verify(customResourceFacade, never()).patchResource(any(), any()); + verify(customResourceFacade, never()).patchStatus(eq(testCustomResource), any()); } @Test void addsFinalizerIfNotMarkedForDeletionAndEmptyCustomResourceReturned() { removeFinalizers(testCustomResource); - when(reconciler.reconcile(eq(testCustomResource), any())) - .thenReturn(UpdateControl.noUpdate()); + reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); + when(customResourceFacade.patchResourceWithSSA(any())).thenReturn(testCustomResource); - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + var postExecControl = + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - assertEquals(1, testCustomResource.getMetadata().getFinalizers().size()); - verify(customResourceFacade, times(1)).replaceWithLock(any()); + verify(customResourceFacade, times(1)) + .patchResourceWithSSA(argThat(a -> !a.getMetadata().getFinalizers().isEmpty())); + assertThat(postExecControl.updateIsStatusPatch()).isFalse(); + assertThat(postExecControl.getUpdatedCustomResource()).isPresent(); } @Test @@ -257,7 +375,7 @@ void doesNotCallDeleteIfMarkedForDeletionButNotOurFinalizer() { reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - verify(customResourceFacade, never()).replaceWithLock(any()); + verify(customResourceFacade, never()).patchResource(any(), any()); verify(reconciler, never()).cleanup(eq(testCustomResource), any()); } @@ -276,25 +394,23 @@ void propagatesRetryInfoToContextIfFinalizerSet() { reconciliationDispatcher.handleExecution( new ExecutionScope( - testCustomResource, - new RetryInfo() { - @Override - public int getAttemptCount() { - return 2; - } - - @Override - public boolean isLastAttempt() { - return true; - } - })); - - ArgumentCaptor contextArgumentCaptor = - ArgumentCaptor.forClass(Context.class); - verify(reconciler, times(1)) - .reconcile(any(), contextArgumentCaptor.capture()); - Context context = contextArgumentCaptor.getValue(); - final var retryInfo = context.getRetryInfo().get(); + 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); } @@ -303,14 +419,14 @@ public boolean isLastAttempt() { void setReScheduleToPostExecutionControlFromUpdateControl() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); - when(reconciler.reconcile(eq(testCustomResource), any())) - .thenReturn( - UpdateControl.updateStatus(testCustomResource).rescheduleAfter(1000L)); + reconciler.reconcile = + (r, c) -> UpdateControl.patchStatus(testCustomResource).rescheduleAfter(1000L); PostExecutionControl control = reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - assertThat(control.getReScheduleDelay().get()).isEqualTo(1000L); + assertThat(control.getReScheduleDelay().orElseGet(() -> fail("Missing optional"))) + .isEqualTo(1000L); } @Test @@ -318,147 +434,202 @@ void reScheduleOnDeleteWithoutFinalizerRemoval() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); markForDeletion(testCustomResource); - when(reconciler.cleanup(eq(testCustomResource), any())) - .thenReturn(DeleteControl.noFinalizerRemoval().rescheduleAfter(1, TimeUnit.SECONDS)); + reconciler.cleanup = + (r, c) -> DeleteControl.noFinalizerRemoval().rescheduleAfter(1, TimeUnit.SECONDS); PostExecutionControl control = reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); - assertThat(control.getReScheduleDelay().get()).isEqualTo(1000L); + assertThat(control.getReScheduleDelay().orElseGet(() -> fail("Missing optional"))) + .isEqualTo(1000L); } @Test - void setObservedGenerationForStatusIfNeeded() { + void doesNotUpdatesObservedGenerationIfStatusIsNotPatchedWhenUsingSSA() throws Exception { var observedGenResource = createObservedGenCustomResource(); Reconciler reconciler = mock(Reconciler.class); - ControllerConfiguration config = - mock(ControllerConfiguration.class); + final var config = MockControllerConfiguration.forResource(ObservedGenCustomResource.class); CustomResourceFacade facade = mock(CustomResourceFacade.class); - var dispatcher = init(observedGenResource, reconciler, config, facade, true); - when(config.isGenerationAware()).thenReturn(true); - when(reconciler.reconcile(any(), any())) - .thenReturn(UpdateControl.updateStatus(observedGenResource)); - when(facade.updateStatus(observedGenResource)).thenReturn(observedGenResource); - - PostExecutionControl control = dispatcher.handleExecution( - executionScopeWithCREvent(observedGenResource)); - assertThat(control.getUpdatedCustomResource().get().getStatus().getObservedGeneration()) - .isEqualTo(1L); - } - - @Test - void updatesObservedGenerationOnNoUpdateUpdateControl() { - var observedGenResource = createObservedGenCustomResource(); - - Reconciler reconciler = mock(Reconciler.class); - ControllerConfiguration config = - mock(ControllerConfiguration.class); - CustomResourceFacade facade = mock(CustomResourceFacade.class); - when(config.isGenerationAware()).thenReturn(true); - when(reconciler.reconcile(any(), any())) - .thenReturn(UpdateControl.noUpdate()); - when(facade.updateStatus(observedGenResource)).thenReturn(observedGenResource); + 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().get().getStatus().getObservedGeneration()) - .isEqualTo(1L); + PostExecutionControl control = + dispatcher.handleExecution(executionScopeWithCREvent(observedGenResource)); + assertThat(control.getUpdatedCustomResource()).isEmpty(); } @Test - void updateObservedGenerationOnCustomResourceUpdate() { + void doesNotPatchObservedGenerationOnCustomResourcePatch() throws Exception { var observedGenResource = createObservedGenCustomResource(); Reconciler reconciler = mock(Reconciler.class); - ControllerConfiguration config = - mock(ControllerConfiguration.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.updateResource(observedGenResource)); - when(facade.replaceWithLock(any())).thenReturn(observedGenResource); - when(facade.updateStatus(observedGenResource)).thenReturn(observedGenResource); - var dispatcher = init(observedGenResource, reconciler, config, facade, true); + .thenReturn(UpdateControl.patchResource(observedGenResource)); + when(facade.patchResource(any(), any())).thenReturn(observedGenResource); + var dispatcher = init(observedGenResource, reconciler, config, facade, false); + + dispatcher.handleExecution(executionScopeWithCREvent(observedGenResource)); - PostExecutionControl control = dispatcher.handleExecution( - executionScopeWithCREvent(observedGenResource)); - assertThat(control.getUpdatedCustomResource().get().getStatus().getObservedGeneration()) - .isEqualTo(1L); + verify(facade, never()).patchStatus(any(), any()); } @Test void callErrorStatusHandlerIfImplemented() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); - when(reconciler.reconcile(any(), any())) - .thenThrow(new IllegalStateException("Error Status Test")); - when(((ErrorStatusHandler) reconciler).updateErrorStatus(any(), any(), any())).then(a -> { - testCustomResource.getStatus().setConfigMapStatus(ERROR_MESSAGE); - return Optional.of(testCustomResource); - }); + 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( - testCustomResource, - new RetryInfo() { - @Override - public int getAttemptCount() { - return 2; - } + 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()); + } - @Override - public boolean isLastAttempt() { - return true; - } - })); + @Test + void callErrorStatusHandlerEvenOnFirstError() { + testCustomResource.addFinalizer(DEFAULT_FINALIZER); - verify(customResourceFacade, times(1)).updateStatus(testCustomResource); - verify(((ErrorStatusHandler) reconciler), times(1)).updateErrorStatus(eq(testCustomResource), - any(), any()); + 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 callErrorStatusHandlerEvenOnFirstError() { + 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(); + } - when(reconciler.reconcile(any(), any())) - .thenThrow(new IllegalStateException("Error Status Test")); - when(((ErrorStatusHandler) reconciler).updateErrorStatus(any(), any(), any())).then(a -> { - testCustomResource.getStatus().setConfigMapStatus(ERROR_MESSAGE); - return Optional.of(testCustomResource); - }); - reconciliationDispatcher.handleExecution( - new ExecutionScope( - testCustomResource, null)); - verify(customResourceFacade, times(1)).updateStatus(testCustomResource); - verify(((ErrorStatusHandler) reconciler), times(1)).updateErrorStatus(eq(testCustomResource), - any(), any()); + @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 schedulesReconciliationIfMaxDelayIsSet() { + void errorStatusHandlerCanPatchResource() { testCustomResource.addFinalizer(DEFAULT_FINALIZER); + reconciler.reconcile = + (r, c) -> { + throw new IllegalStateException("Error Status Test"); + }; + reconciler.errorHandler = () -> ErrorStatusUpdateControl.patchStatus(testCustomResource); - when(reconciler.reconcile(eq(testCustomResource), any())) - .thenReturn(UpdateControl.noUpdate()); + reconciliationDispatcher.handleExecution( + new ExecutionScope(null).setResource(testCustomResource)); - PostExecutionControl control = - reconciliationDispatcher.handleExecution(executionScopeWithCREvent(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"); + }; - assertThat(control.getReScheduleDelay()).isPresent() - .hasValue(TimeUnit.HOURS.toMillis(RECONCILIATION_MAX_INTERVAL)); + 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); - when(reconciler.reconcile(eq(testCustomResource), any())) - .thenReturn(UpdateControl.noUpdate()); - when(configuration.reconciliationMaxInterval()) + reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); + when(reconciliationDispatcher.configuration().maxReconciliationInterval()) .thenReturn(Optional.empty()); PostExecutionControl control = @@ -467,6 +638,71 @@ void canSkipSchedulingMaxDelayIf() { assertThat(control.getReScheduleDelay()).isNotPresent(); } + @Test + void retriesAddingFinalizerWithoutSSA() { + initConfigService(false); + reconciliationDispatcher = + init(testCustomResource, reconciler, null, customResourceFacade, true); + + removeFinalizers(testCustomResource); + reconciler.reconcile = (r, c) -> UpdateControl.noUpdate(); + when(customResourceFacade.patchResource(any(), any())) + .thenThrow(new KubernetesClientException(null, 409, null)) + .thenReturn(testCustomResource); + when(customResourceFacade.getResource(any(), any())) + .then( + (Answer) + invocationOnMock -> { + testCustomResource.getFinalizers().clear(); + return testCustomResource; + }); + + reconciliationDispatcher.handleExecution(executionScopeWithCREvent(testCustomResource)); + + verify(customResourceFacade, times(2)).patchResource(any(), any()); + } + + @Test + void reSchedulesFromErrorHandler() { + var delay = 1000L; + 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()); @@ -476,8 +712,10 @@ private ObservedGenCustomResource createObservedGenCustomResource() { return observedGenCustomResource; } - private void markForDeletion(CustomResource customResource) { - customResource.getMetadata().setDeletionTimestamp("2019-8-10"); + TestCustomResource createResourceWithFinalizer() { + var resourceWithFinalizer = TestUtils.testCustomResource(); + resourceWithFinalizer.addFinalizer(DEFAULT_FINALIZER); + return resourceWithFinalizer; } private void removeFinalizers(CustomResource customResource) { @@ -485,6 +723,37 @@ private void removeFinalizers(CustomResource customResource) { } public ExecutionScope executionScopeWithCREvent(T resource) { - return new ExecutionScope<>(resource, null); + 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 index 6aa974e695..9cea4790b0 100644 --- 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 @@ -2,7 +2,9 @@ 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; @@ -19,6 +21,11 @@ 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); } @@ -28,9 +35,20 @@ public void setUpSource(S source, T eventHandler) { } 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/CachingEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/CachingEventSourceTest.java deleted file mode 100644 index 29cf0a981d..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/CachingEventSourceTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source; - -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 CachingEventSourceTest extends - AbstractEventSourceTestBase, EventHandler> { - - @BeforeEach - public void setup() { - setUpSource(new SimpleCachingEventSource()); - } - - @Test - public void putsNewResourceIntoCacheAndProducesEvent() { - source.handleEvent(testResource1(), testResource1ID()); - - verify(eventHandler, times(1)).handleEvent(eq(new Event(testResource1ID()))); - assertThat(source.getCachedValue(testResource1ID())).isPresent(); - } - - @Test - public void propagatesEventIfResourceChanged() { - var res2 = testResource1(); - res2.setValue("changedValue"); - source.handleEvent(testResource1(), testResource1ID()); - source.handleEvent(res2, testResource1ID()); - - - verify(eventHandler, times(2)).handleEvent(eq(new Event(testResource1ID()))); - assertThat(source.getCachedValue(testResource1ID()).get()).isEqualTo(res2); - } - - @Test - public void noEventPropagatedIfTheResourceIsNotChanged() { - source.handleEvent(testResource1(), testResource1ID()); - source.handleEvent(testResource1(), testResource1ID()); - - verify(eventHandler, times(1)).handleEvent(eq(new Event(testResource1ID()))); - assertThat(source.getCachedValue(testResource1ID())).isPresent(); - } - - @Test - public void propagatesEventOnDeleteIfThereIsPrevResourceInCache() { - source.handleEvent(testResource1(), testResource1ID()); - source.handleDelete(testResource1ID()); - - verify(eventHandler, times(2)).handleEvent(eq(new Event(testResource1ID()))); - assertThat(source.getCachedValue(testResource1ID())).isNotPresent(); - } - - @Test - public void noEventOnDeleteIfResourceWasNotInCacheBefore() { - source.handleDelete(testResource1ID()); - - verify(eventHandler, times(0)).handleEvent(eq(new Event(testResource1ID()))); - } - - - public static class SimpleCachingEventSource - extends CachingEventSource { - public SimpleCachingEventSource() { - super(SampleExternalResource.class); - } - } - -} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/CustomResourceSelectorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/CustomResourceSelectorTest.java deleted file mode 100644 index ce865b7888..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/CustomResourceSelectorTest.java +++ /dev/null @@ -1,183 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source; - -import java.util.Date; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; - -import org.awaitility.core.ConditionTimeoutException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.internal.util.collections.Sets; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; -import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer; -import io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; -import io.javaoperatorsdk.operator.api.config.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.sample.simple.TestCustomResource; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; -import static org.hamcrest.Matchers.greaterThan; -import static org.hamcrest.Matchers.is; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.when; - -@EnableKubernetesMockClient(crud = true, https = false) -public class CustomResourceSelectorTest { - - private static final Logger LOGGER = LoggerFactory.getLogger(CustomResourceSelectorTest.class); - public static final String NAMESPACE = "test"; - - KubernetesMockServer server; - KubernetesClient client; - ConfigurationService configurationService; - - @SuppressWarnings("unchecked") - @BeforeEach - void setUpResources() { - configurationService = spy(ConfigurationService.class); - when(configurationService.checkCRDAndValidateLocalModel()).thenReturn(false); - when(configurationService.getVersion()).thenReturn(new Version("1", "1", new Date())); - when(configurationService.getConfigurationFor(any(MyController.class))).thenReturn( - new MyConfiguration(configurationService, null)); - } - - @Test - void resourceWatchedByLabel() { - assertThat(server).isNotNull(); - assertThat(client).isNotNull(); - - Operator o1 = new Operator(client, configurationService); - Operator o2 = new Operator(client, configurationService); - try { - AtomicInteger c1 = new AtomicInteger(); - AtomicInteger c1err = new AtomicInteger(); - AtomicInteger c2 = new AtomicInteger(); - AtomicInteger c2err = new AtomicInteger(); - - o1.register( - new MyController( - resource -> { - if ("foo".equals(resource.getMetadata().getName())) { - c1.incrementAndGet(); - } - if ("bar".equals(resource.getMetadata().getName())) { - c1err.incrementAndGet(); - } - }), - new MyConfiguration(configurationService, "app=foo")); - o1.start(); - o2.register( - new MyController( - resource -> { - if ("bar".equals(resource.getMetadata().getName())) { - c2.incrementAndGet(); - } - if ("foo".equals(resource.getMetadata().getName())) { - c2err.incrementAndGet(); - } - }), - new MyConfiguration(configurationService, "app=bar")); - o2.start(); - - client.resources(TestCustomResource.class).inNamespace(NAMESPACE).create(newMyResource("foo", - NAMESPACE)); - client.resources(TestCustomResource.class).inNamespace(NAMESPACE).create(newMyResource("bar", - NAMESPACE)); - - await() - .atMost(5, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) - .until(() -> c1.get() == 1 && c1err.get() == 0); - await() - .atMost(5, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) - .until(() -> c2.get() == 1 && c2err.get() == 0); - - assertThrows( - ConditionTimeoutException.class, - () -> await().atMost(2, TimeUnit.SECONDS).untilAtomic(c1err, is(greaterThan(0)))); - assertThrows( - ConditionTimeoutException.class, - () -> await().atMost(2, TimeUnit.SECONDS).untilAtomic(c2err, is(greaterThan(0)))); - } finally { - o1.stop(); - o2.stop(); - } - - } - - public TestCustomResource newMyResource(String app, String namespace) { - TestCustomResource resource = new TestCustomResource(); - resource.setMetadata(new ObjectMetaBuilder().withName(app).addToLabels("app", app).build()); - resource.getMetadata().setNamespace(namespace); - return resource; - } - - public static class MyConfiguration - implements - io.javaoperatorsdk.operator.api.config.ControllerConfiguration { - - private final String labelSelector; - private final ConfigurationService service; - - public MyConfiguration(ConfigurationService configurationService, String labelSelector) { - this.labelSelector = labelSelector; - this.service = configurationService; - } - - @Override - public String getLabelSelector() { - return labelSelector; - } - - @Override - public String getAssociatedReconcilerClassName() { - return MyController.class.getCanonicalName(); - } - - @Override - public Set getNamespaces() { - return Sets.newSet(NAMESPACE); - } - - @Override - public ConfigurationService getConfigurationService() { - return service; - } - } - - @ControllerConfiguration(namespaces = NAMESPACE) - public static class MyController implements Reconciler { - - private final Consumer consumer; - - public MyController(Consumer consumer) { - this.consumer = consumer; - } - - @Override - public UpdateControl reconcile( - TestCustomResource resource, Context context) { - - LOGGER.info("Received event on: {}", resource); - - consumer.accept(resource); - - return UpdateControl.updateStatus(resource); - } - } -} 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/OnceWhitelistEventFilterEventFilterTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/OnceWhitelistEventFilterEventFilterTest.java deleted file mode 100644 index f82bea55c7..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/OnceWhitelistEventFilterEventFilterTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source; - -import org.junit.jupiter.api.Test; - -import io.javaoperatorsdk.operator.TestUtils; -import io.javaoperatorsdk.operator.processing.event.ResourceID; -import io.javaoperatorsdk.operator.processing.event.source.controller.OnceWhitelistEventFilterEventFilter; - -import static io.javaoperatorsdk.operator.TestUtils.testCustomResource; -import static org.assertj.core.api.Assertions.assertThat; - -class OnceWhitelistEventFilterEventFilterTest { - - private OnceWhitelistEventFilterEventFilter filter = new OnceWhitelistEventFilterEventFilter<>(); - - @Test - public void notAcceptCustomResourceNotWhitelisted() { - assertThat(filter.acceptChange(null, - testCustomResource(), testCustomResource())).isFalse(); - } - - @Test - public void allowCustomResourceWhitelisted() { - var cr = TestUtils.testCustomResource(); - - filter.whitelistNextEvent(ResourceID.fromResource(cr)); - - assertThat(filter.acceptChange(null, cr, cr)).isTrue(); - } - - @Test - public void allowCustomResourceWhitelistedOnlyOnce() { - var cr = TestUtils.testCustomResource(); - - filter.whitelistNextEvent(ResourceID.fromResource(cr)); - - assertThat(filter.acceptChange(null, cr, cr)).isTrue(); - assertThat(filter.acceptChange(null, cr, cr)).isFalse(); - } - -} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventFilterTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventFilterTest.java deleted file mode 100644 index 1dd9972dea..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/ResourceEventFilterTest.java +++ /dev/null @@ -1,165 +0,0 @@ -package io.javaoperatorsdk.operator.processing.event.source; - -import java.util.List; -import java.util.Objects; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.api.model.KubernetesResourceList; -import io.fabric8.kubernetes.client.dsl.MixedOperation; -import io.fabric8.kubernetes.client.dsl.Resource; -import io.javaoperatorsdk.operator.TestUtils; -import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.config.DefaultControllerConfiguration; -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.controller.ControllerResourceEventSource; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceAction; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter; -import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; - -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -class ResourceEventFilterTest { - public static final String FINALIZER = "finalizer"; - - private EventHandler eventHandler; - - @BeforeEach - public void before() { - this.eventHandler = mock(EventHandler.class); - } - - private ControllerResourceEventSource init(Controller controller) { - var eventSource = new ControllerResourceEventSource<>(controller); - eventSource.setEventHandler(eventHandler); - return eventSource; - } - - @Test - public void eventFilteredByCustomPredicate() { - var config = new TestControllerConfig( - FINALIZER, - false, - (configuration, oldResource, newResource) -> oldResource == null || !Objects.equals( - oldResource.getStatus().getConfigMapStatus(), - newResource.getStatus().getConfigMapStatus())); - - final var eventSource = init(new TestController(config)); - - TestCustomResource cr = TestUtils.testCustomResource(); - cr.getMetadata().setFinalizers(List.of(FINALIZER)); - cr.getMetadata().setGeneration(1L); - cr.getStatus().setConfigMapStatus("1"); - - eventSource.eventReceived(ResourceAction.UPDATED, cr, null); - verify(eventHandler, times(1)).handleEvent(any()); - - cr.getMetadata().setGeneration(1L); - cr.getStatus().setConfigMapStatus("1"); - - eventSource.eventReceived(ResourceAction.UPDATED, cr, cr); - verify(eventHandler, times(1)).handleEvent(any()); - } - - @Test - public void eventFilteredByCustomPredicateAndGenerationAware() { - var config = new TestControllerConfig( - FINALIZER, - true, - (configuration, oldResource, newResource) -> oldResource == null || !Objects.equals( - oldResource.getStatus().getConfigMapStatus(), - newResource.getStatus().getConfigMapStatus())); - - final var eventSource = init(new TestController(config)); - - TestCustomResource cr = TestUtils.testCustomResource(); - cr.getMetadata().setFinalizers(List.of(FINALIZER)); - cr.getMetadata().setGeneration(1L); - cr.getStatus().setConfigMapStatus("1"); - - TestCustomResource cr2 = TestUtils.testCustomResource(); - cr.getMetadata().setFinalizers(List.of(FINALIZER)); - cr.getMetadata().setGeneration(2L); - cr.getStatus().setConfigMapStatus("1"); - - eventSource.eventReceived(ResourceAction.UPDATED, cr, cr2); - verify(eventHandler, times(1)).handleEvent(any()); - - cr.getMetadata().setGeneration(1L); - cr.getStatus().setConfigMapStatus("2"); - - eventSource.eventReceived(ResourceAction.UPDATED, cr, cr); - verify(eventHandler, times(1)).handleEvent(any()); - } - - @Test - public void eventAlwaysFilteredByCustomPredicate() { - var config = new TestControllerConfig( - FINALIZER, - false, - (configuration, oldResource, newResource) -> !Objects.equals( - oldResource.getStatus().getConfigMapStatus(), - newResource.getStatus().getConfigMapStatus())); - - final var eventSource = init(new TestController(config)); - - TestCustomResource cr = TestUtils.testCustomResource(); - cr.getMetadata().setGeneration(1L); - cr.getStatus().setConfigMapStatus("1"); - - eventSource.eventReceived(ResourceAction.UPDATED, cr, cr); - verify(eventHandler, times(0)).handleEvent(any()); - } - - private static class TestControllerConfig extends ControllerConfig { - public TestControllerConfig(String finalizer, boolean generationAware, - ResourceEventFilter eventFilter) { - super(finalizer, generationAware, eventFilter, TestCustomResource.class); - } - } - - private static class ControllerConfig extends - DefaultControllerConfiguration { - - public ControllerConfig(String finalizer, boolean generationAware, - ResourceEventFilter eventFilter, Class customResourceClass) { - super( - null, - null, - null, - finalizer, - generationAware, - null, - null, - null, - eventFilter, - customResourceClass, - null, null); - } - } - - private static class TestController extends Controller { - - public TestController(ControllerConfiguration configuration) { - super(null, configuration, null); - } - - @Override - public EventSourceManager getEventSourceManager() { - return mock(EventSourceManager.class); - } - - @Override - public MixedOperation, Resource> getCRClient() { - return mock(MixedOperation.class); - } - } - -} 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 index bf2ff42c96..86eccbc57b 100644 --- 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 @@ -20,11 +20,11 @@ public static SampleExternalResource testResource2() { return new SampleExternalResource(NAME_2, DEFAULT_VALUE_2); } - public static ResourceID testResource1ID() { + public static ResourceID primaryID1() { return new ResourceID(NAME_1, "testns"); } - public static ResourceID testResource2ID() { + public static ResourceID primaryID2() { return new ResourceID(NAME_2, "testns"); } @@ -56,10 +56,8 @@ public SampleExternalResource setValue(String value) { @Override public boolean equals(Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; + 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); } 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/ControllerResourceEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSourceTest.java deleted file mode 100644 index 5859115ee2..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSourceTest.java +++ /dev/null @@ -1,177 +0,0 @@ -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.fabric8.kubernetes.api.model.KubernetesResourceList; -import io.fabric8.kubernetes.client.dsl.MixedOperation; -import io.fabric8.kubernetes.client.dsl.Resource; -import io.javaoperatorsdk.operator.TestUtils; -import io.javaoperatorsdk.operator.api.config.DefaultControllerConfiguration; -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.ResourceID; -import io.javaoperatorsdk.operator.processing.event.source.AbstractEventSourceTestBase; -import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; - -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -class ControllerResourceEventSourceTest extends - AbstractEventSourceTestBase, EventHandler> { - - public static final String FINALIZER = "finalizer"; - private static final MixedOperation, Resource> client = - mock(MixedOperation.class); - - private TestController testController = new TestController(true); - - @BeforeEach - public void setup() { - setUpSource(new ControllerResourceEventSource<>(testController), false); - } - - @Test - public 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 - public 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 - public 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 - public void handlesAllEventIfNotGenerationAware() { - source = - new ControllerResourceEventSource<>(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 - public void eventWithNoGenerationProcessedIfNoFinalizer() { - TestCustomResource customResource1 = TestUtils.testCustomResource(); - - source.eventReceived(ResourceAction.UPDATED, customResource1, customResource1); - - verify(eventHandler, times(1)).handleEvent(any()); - } - - @Test - public void handlesNextEventIfWhitelisted() { - TestCustomResource customResource = TestUtils.testCustomResource(); - customResource.getMetadata().setFinalizers(List.of(FINALIZER)); - source.whitelistNextEvent(ResourceID.fromResource(customResource)); - - source.eventReceived(ResourceAction.UPDATED, customResource, customResource); - - verify(eventHandler, times(1)).handleEvent(any()); - } - - @Test - public void notHandlesNextEventIfNotWhitelisted() { - TestCustomResource customResource = TestUtils.testCustomResource(); - customResource.getMetadata().setFinalizers(List.of(FINALIZER)); - - source.eventReceived(ResourceAction.UPDATED, customResource, customResource); - - verify(eventHandler, times(0)).handleEvent(any()); - } - - @Test - public 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)); - } - - private static class TestController extends Controller { - - private EventSourceManager eventSourceManager = - mock(EventSourceManager.class); - - public TestController(boolean generationAware) { - super(null, new TestConfiguration(generationAware), null); - } - - @Override - public EventSourceManager getEventSourceManager() { - return eventSourceManager; - } - - @Override - public MixedOperation, Resource> getCRClient() { - return client; - } - } - - private static class TestConfiguration extends - DefaultControllerConfiguration { - - public TestConfiguration(boolean generationAware) { - super( - null, - null, - null, - FINALIZER, - generationAware, - null, - null, - null, - null, - TestCustomResource.class, - null, - null); - } - } -} 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 index 8486f705ca..c85f40d5ad 100644 --- 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 @@ -1,100 +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.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.AbstractEventSourceTestBase; -import io.javaoperatorsdk.operator.processing.event.source.Cache; +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.atLeast; -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 PerResourcePollingEventSourceTest extends - AbstractEventSourceTestBase, EventHandler> { - - public static final int PERIOD = 80; - private PerResourcePollingEventSource.ResourceSupplier supplier = - mock(PerResourcePollingEventSource.ResourceSupplier.class); - private Cache resourceCache = mock(Cache.class); - private TestCustomResource testCustomResource = TestUtils.testCustomResource(); +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.getResource(any())) - .thenReturn(Optional.of(SampleExternalResource.testResource1())); - - setUpSource(new PerResourcePollingEventSource<>(supplier, resourceCache, PERIOD, - SampleExternalResource.class)); + 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 - public void pollsTheResourceAfterAwareOfIt() throws InterruptedException { + void pollsTheResourceAfterAwareOfIt() { source.onResourceCreated(testCustomResource); - Thread.sleep(3 * PERIOD); - verify(supplier, atLeast(2)).getResource(eq(testCustomResource)); - verify(eventHandler, times(1)).handleEvent(any()); + 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 - public void registeringTaskOnAPredicate() throws InterruptedException { - setUpSource(new PerResourcePollingEventSource<>(supplier, resourceCache, PERIOD, - testCustomResource -> testCustomResource.getMetadata().getGeneration() > 1, - SampleExternalResource.class)); + 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); - Thread.sleep(2 * PERIOD); - verify(supplier, times(0)).getResource(eq(testCustomResource)); + await() + .pollDelay(Duration.ofMillis(2 * PERIOD)) + .untilAsserted(() -> verify(supplier, times(0)).fetchResources(eq(testCustomResource))); + testCustomResource.getMetadata().setGeneration(2L); source.onResourceUpdated(testCustomResource, testCustomResource); - Thread.sleep(2 * PERIOD); - - verify(supplier, atLeast(1)).getResource(eq(testCustomResource)); + await() + .pollDelay(Duration.ofMillis(2 * PERIOD)) + .untilAsserted(() -> verify(supplier, atLeast(1)).fetchResources(eq(testCustomResource))); } @Test - public void propagateEventOnDeletedResource() throws InterruptedException { + void propagateEventOnDeletedResource() { source.onResourceCreated(testCustomResource); - when(supplier.getResource(any())) - .thenReturn(Optional.of(SampleExternalResource.testResource1())) - .thenReturn(Optional.empty()); - - Thread.sleep(3 * PERIOD); - verify(supplier, atLeast(2)).getResource(eq(testCustomResource)); - verify(eventHandler, times(2)).handleEvent(any()); + 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 - public void getsValueFromCacheOrSupplier() throws InterruptedException { + void getSecondaryResourceInitiatesFetchJustForFirstTime() { source.onResourceCreated(testCustomResource); - when(supplier.getResource(any())) - .thenReturn(Optional.empty()) - .thenReturn(Optional.of(SampleExternalResource.testResource1())); + when(supplier.fetchResources(any())) + .thenReturn(Set.of(SampleExternalResource.testResource1())) + .thenReturn( + Set.of(SampleExternalResource.testResource1(), SampleExternalResource.testResource2())); - Thread.sleep(PERIOD / 2); + var value = source.getSecondaryResources(testCustomResource); - var value = source.getValueFromCacheOrSupplier(ResourceID.fromResource(testCustomResource)); + verify(supplier, times(1)).fetchResources(eq(testCustomResource)); + verify(eventHandler, never()).handleEvent(any()); + assertThat(value).hasSize(1); - Thread.sleep(PERIOD * 2); + value = source.getSecondaryResources(testCustomResource); - assertThat(value).isPresent(); + 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 index d866791147..448537e9aa 100644 --- 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 @@ -1,28 +1,44 @@ package io.javaoperatorsdk.operator.processing.event.source.polling; +import java.time.Duration; import java.util.HashMap; import java.util.Map; -import java.util.function.Supplier; +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, EventHandler> { + extends AbstractEventSourceTestBase< + PollingEventSource, EventHandler> { - private Supplier> supplier = mock(Supplier.class); - private PollingEventSource pollingEventSource = - new PollingEventSource<>(supplier, 50, SampleExternalResource.class); + 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() { @@ -30,44 +46,83 @@ public void setup() { } @Test - public void pollsAndProcessesEvents() throws InterruptedException { - when(supplier.get()).thenReturn(testResponseWithTwoValues()); + void pollsAndProcessesEvents() throws InterruptedException { + when(resourceFetcher.fetchResources()).thenReturn(testResponseWithTwoValues()); pollingEventSource.start(); - Thread.sleep(100); + Thread.sleep(DEFAULT_WAIT_PERIOD); verify(eventHandler, times(2)).handleEvent(any()); } @Test - public void propagatesEventForRemovedResources() throws InterruptedException { - when(supplier.get()).thenReturn(testResponseWithTwoValues()) + void propagatesEventForRemovedResources() throws InterruptedException { + when(resourceFetcher.fetchResources()) + .thenReturn(testResponseWithTwoValues()) .thenReturn(testResponseWithOneValue()); pollingEventSource.start(); - Thread.sleep(150); + Thread.sleep(DEFAULT_WAIT_PERIOD); verify(eventHandler, times(3)).handleEvent(any()); } @Test - public void doesNotPropagateEventIfResourceNotChanged() throws InterruptedException { - when(supplier.get()).thenReturn(testResponseWithTwoValues()); + 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(250); + Thread.sleep(DEFAULT_WAIT_PERIOD); verify(eventHandler, times(2)).handleEvent(any()); } - private Map testResponseWithOneValue() { - Map res = new HashMap<>(); - res.put(testResource1ID(), testResource1()); + @Test + void updatesHealthIndicatorBasedOnExceptionsInFetcher() { + 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 testResponseWithTwoValues() { - Map res = new HashMap<>(); - res.put(testResource1ID(), testResource1()); - res.put(testResource2ID(), testResource2()); + 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 index 2dc42c0b16..825bc6adab 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSourceTest.java @@ -1,6 +1,5 @@ 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; @@ -12,6 +11,7 @@ import org.junit.jupiter.api.Test; import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.health.Status; import io.javaoperatorsdk.operator.processing.event.Event; import io.javaoperatorsdk.operator.processing.event.EventHandler; import io.javaoperatorsdk.operator.processing.event.ResourceID; @@ -23,13 +23,12 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; class TimerEventSourceTest - extends - AbstractEventSourceTestBase, CapturingEventHandler> { + 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()); @@ -37,59 +36,68 @@ public void setup() { @Test public void schedulesOnce() { - TestCustomResource customResource = TestUtils.testCustomResource(); + var resourceID = ResourceID.fromResource(TestUtils.testCustomResource()); - source.scheduleOnce(customResource, PERIOD); + source.scheduleOnce(resourceID, PERIOD); untilAsserted(() -> assertThat(eventHandler.events).hasSize(1)); untilAsserted(PERIOD * 2, 0, () -> assertThat(eventHandler.events).hasSize(1)); + assertThat(source.getStatus()).isEqualTo(Status.HEALTHY); } @Test public void canCancelOnce() { - TestCustomResource customResource = TestUtils.testCustomResource(); + var resourceID = ResourceID.fromResource(TestUtils.testCustomResource()); - source.scheduleOnce(customResource, PERIOD); - source.cancelOnceSchedule(ResourceID.fromResource(customResource)); + source.scheduleOnce(resourceID, PERIOD); + source.cancelOnceSchedule(resourceID); untilAsserted(() -> assertThat(eventHandler.events).isEmpty()); + assertThat(source.getStatus()).isEqualTo(Status.HEALTHY); } @Test public void canRescheduleOnceEvent() { - TestCustomResource customResource = TestUtils.testCustomResource(); + var resourceID = ResourceID.fromResource(TestUtils.testCustomResource()); - source.scheduleOnce(customResource, PERIOD); - source.scheduleOnce(customResource, 2 * PERIOD); + source.scheduleOnce(resourceID, PERIOD); + source.scheduleOnce(resourceID, 2 * PERIOD); untilAsserted(PERIOD * 2, PERIOD, () -> assertThat(eventHandler.events).hasSize(1)); + assertThat(source.getStatus()).isEqualTo(Status.HEALTHY); } @Test public void deRegistersOnceEventSources() { TestCustomResource customResource = TestUtils.testCustomResource(); - source.scheduleOnce(customResource, PERIOD); + source.scheduleOnce(ResourceID.fromResource(customResource), PERIOD); source.onResourceDeleted(customResource); untilAsserted(() -> assertThat(eventHandler.events).isEmpty()); + assertThat(source.getStatus()).isEqualTo(Status.HEALTHY); } @Test - public void eventNotRegisteredIfStopped() throws IOException { - TestCustomResource customResource = TestUtils.testCustomResource(); + public void eventNotRegisteredIfStopped() { + var resourceID = ResourceID.fromResource(TestUtils.testCustomResource()); + assertThat(source.getStatus()).isEqualTo(Status.HEALTHY); source.stop(); - assertThatExceptionOfType(IllegalStateException.class).isThrownBy( - () -> source.scheduleOnce(customResource, PERIOD)); + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> source.scheduleOnce(resourceID, PERIOD)); + assertThat(source.getStatus()).isEqualTo(Status.UNHEALTHY); } @Test - public void eventNotFiredIfStopped() throws IOException { - source.scheduleOnce(TestUtils.testCustomResource(), PERIOD); + public void eventNotFiredIfStopped() { + source.scheduleOnce(ResourceID.fromResource(TestUtils.testCustomResource()), PERIOD); + assertThat(source.getStatus()).isEqualTo(Status.HEALTHY); + source.stop(); untilAsserted(() -> assertThat(eventHandler.events).isEmpty()); + assertThat(source.getStatus()).isEqualTo(Status.UNHEALTHY); } private void untilAsserted(ThrowingRunnable assertion) { diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java index 0f8e44d1b2..1659995877 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java @@ -4,35 +4,10 @@ import org.junit.jupiter.api.Test; -import static io.javaoperatorsdk.operator.processing.retry.GenericRetry.DEFAULT_INITIAL_INTERVAL; import static org.assertj.core.api.Assertions.assertThat; public class GenericRetryExecutionTest { - @Test - public void forFirstBackOffAlwaysReturnsInitialInterval() { - assertThat(getDefaultRetryExecution().nextDelay().get()).isEqualTo(DEFAULT_INITIAL_INTERVAL); - } - - @Test - public void delayIsMultipliedEveryNextDelayCall() { - RetryExecution retryExecution = getDefaultRetryExecution(); - - Optional res = callNextDelayNTimes(retryExecution, 1); - assertThat(res.get()).isEqualTo(DEFAULT_INITIAL_INTERVAL); - - res = retryExecution.nextDelay(); - assertThat(res.get()) - .isEqualTo((long) (DEFAULT_INITIAL_INTERVAL * GenericRetry.DEFAULT_MULTIPLIER)); - - res = retryExecution.nextDelay(); - assertThat(res.get()) - .isEqualTo( - (long) (DEFAULT_INITIAL_INTERVAL - * GenericRetry.DEFAULT_MULTIPLIER - * GenericRetry.DEFAULT_MULTIPLIER)); - } - @Test public void noNextDelayIfMaxAttemptLimitReached() { RetryExecution retryExecution = 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 index 74f58b795f..f06c0035d3 100644 --- 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 @@ -6,8 +6,7 @@ @Group("sample.javaoperatorsdk.io") @Version("v1") -public class ObservedGenCustomResource - extends CustomResource { +public class ObservedGenCustomResource extends CustomResource { @Override protected ObservedGenSpec initSpec() { 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 index 181204bc1c..30fb495c56 100644 --- 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 @@ -14,8 +14,6 @@ public void setValue(String value) { @Override public String toString() { - return "TestCustomResourceSpec{" + - "value='" + value + '\'' + - '}'; + 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 index d4ffee5416..0f685d5b05 100644 --- 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 @@ -1,7 +1,3 @@ package io.javaoperatorsdk.operator.sample.observedgeneration; -import io.javaoperatorsdk.operator.api.ObservedGenerationAwareStatus; - -public class ObservedGenStatus extends ObservedGenerationAwareStatus { - -} +public class ObservedGenStatus {} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/DuplicateCRController.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/DuplicateCRController.java deleted file mode 100644 index 86f0c36261..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/sample/simple/DuplicateCRController.java +++ /dev/null @@ -1,16 +0,0 @@ -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 DuplicateCRController implements Reconciler { - - @Override - public UpdateControl reconcile(TestCustomResource resource, - Context context) { - return UpdateControl.noUpdate(); - } -} 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 index 45bd9cb6ce..761d91dc04 100644 --- 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 @@ -9,5 +9,4 @@ @Version("v1") public class NamespacedTestCustomResource extends CustomResource - implements Namespaced { -} + 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 index ec0e9bffbd..1d7535c9d3 100644 --- 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 @@ -12,10 +12,10 @@ import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.api.reconciler.*; -import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; @ControllerConfiguration(generationAwareEventProcessing = false) -public class TestCustomReconciler implements Reconciler { +public class TestCustomReconciler + implements Reconciler, Cleaner { private static final Logger log = LoggerFactory.getLogger(TestCustomReconciler.class); @@ -35,15 +35,14 @@ public TestCustomReconciler(KubernetesClient kubernetesClient, boolean updateSta } @Override - public DeleteControl cleanup( - TestCustomResource resource, Context context) { - Boolean delete = + public DeleteControl cleanup(TestCustomResource resource, Context context) { + var statusDetails = kubernetesClient .configMaps() .inNamespace(resource.getMetadata().getNamespace()) .withName(resource.getSpec().getConfigMapName()) .delete(); - if (delete) { + if (statusDetails.size() == 1 && statusDetails.get(0).getCauses().isEmpty()) { log.info( "Deleted ConfigMap {} for resource: {}", resource.getSpec().getConfigMapName(), @@ -59,7 +58,7 @@ public DeleteControl cleanup( @Override public UpdateControl reconcile( - TestCustomResource resource, Context context) { + TestCustomResource resource, Context context) { if (!resource.getMetadata().getFinalizers().contains(FINALIZER_NAME)) { throw new IllegalStateException("Finalizer is not present."); } @@ -77,8 +76,8 @@ public UpdateControl reconcile( kubernetesClient .configMaps() .inNamespace(resource.getMetadata().getNamespace()) - .withName(existingConfigMap.getMetadata().getName()) - .createOrReplace(existingConfigMap); + .resource(existingConfigMap) + .createOrReplace(); } else { Map labels = new HashMap<>(); labels.put("managedBy", TestCustomReconciler.class.getSimpleName()); @@ -95,7 +94,8 @@ public UpdateControl reconcile( kubernetesClient .configMaps() .inNamespace(resource.getMetadata().getNamespace()) - .createOrReplace(newConfigMap); + .resource(newConfigMap) + .createOrReplace(); } if (updateStatus) { if (resource.getStatus() == null) { @@ -103,7 +103,7 @@ public UpdateControl reconcile( } resource.getStatus().setConfigMapStatus("ConfigMap Ready"); } - return UpdateControl.updateResource(resource); + return UpdateControl.patchResource(resource); } private Map configMapData(TestCustomResource resource) { 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 index 9a45fbbf93..5327b30a79 100644 --- 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 @@ -9,8 +9,8 @@ public class TestCustomReconcilerOtherV1 implements Reconciler { @Override - public UpdateControl reconcile(TestCustomResourceOtherV1 resource, - Context context) { + public UpdateControl reconcile( + TestCustomResourceOtherV1 resource, Context context) { return UpdateControl.noUpdate(); } } 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 index f768ba491f..6bb572ca4e 100644 --- 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 @@ -7,8 +7,6 @@ @Group("sample.javaoperatorsdk.io") @Version("v1") -@Kind("TestCustomResource") // this is needed to override the automatically generated kind +@Kind("TestCustomResourceOtherV1") // this is needed to override the automatically generated kind public class TestCustomResourceOtherV1 - extends CustomResource { - -} + 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 index 5fd9f49084..69a6b107b2 100644 --- 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 @@ -1,5 +1,7 @@ package io.javaoperatorsdk.operator.sample.simple; +import java.util.Objects; + public class TestCustomResourceSpec { private String configMapName; @@ -46,4 +48,23 @@ public String toString() { + '\'' + '}'; } + + @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 index 620bbaabd8..ab5559d80c 100644 --- 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 @@ -1,5 +1,7 @@ package io.javaoperatorsdk.operator.sample.simple; +import java.util.Objects; + public class TestCustomResourceStatus { private String configMapStatus; @@ -16,4 +18,21 @@ public void setConfigMapStatus(String configMapStatus) { 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-junit5/pom.xml b/operator-framework-junit5/pom.xml index 89a631f0ad..5aeeb92d45 100644 --- a/operator-framework-junit5/pom.xml +++ b/operator-framework-junit5/pom.xml @@ -1,22 +1,15 @@ - + + 4.0.0 - java-operator-sdk io.javaoperatorsdk - 2.1.2-SNAPSHOT + java-operator-sdk + 5.1.5-SNAPSHOT - 4.0.0 operator-framework-junit-5 Operator SDK - Framework - JUnit 5 extension - - 11 - 11 - - io.javaoperatorsdk @@ -39,6 +32,11 @@ org.awaitility awaitility + + org.mockito + mockito-core + test + - \ No newline at end of file + 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 index 839b550231..794bc11d9a 100644 --- 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 @@ -4,64 +4,71 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Locale; -import java.util.UUID; 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.*; +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.HasMetadata; -import io.fabric8.kubernetes.api.model.KubernetesResourceList; -import io.fabric8.kubernetes.api.model.NamespaceBuilder; -import io.fabric8.kubernetes.client.DefaultKubernetesClient; +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.KubernetesResourceUtil; import io.fabric8.kubernetes.client.utils.Utils; -import io.javaoperatorsdk.operator.api.config.BaseConfigurationService; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; -import io.javaoperatorsdk.operator.api.config.Version; +import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; -public abstract class AbstractOperatorExtension implements HasKubernetesClient, - BeforeAllCallback, - BeforeEachCallback, - AfterAllCallback, - AfterEachCallback { +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 ConfigurationService configurationService; 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( - ConfigurationService configurationService, List infrastructure, Duration infrastructureTimeout, boolean oneNamespacePerClass, boolean preserveNamespaceOnError, - boolean waitForNamespaceDeletion) { - - this.kubernetesClient = new DefaultKubernetesClient(); - this.configurationService = configurationService; + 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); @@ -91,8 +98,8 @@ public String getNamespace() { return namespace; } - public NonNamespaceOperation, Resource> resources( - Class type) { + public + NonNamespaceOperation, Resource> resources(Class type) { return kubernetesClient.resources(type).inNamespace(namespace); } @@ -100,41 +107,33 @@ public T get(Class type, String name) { return kubernetesClient.resources(type).inNamespace(namespace).withName(name).get(); } - public T create(Class type, T resource) { - return kubernetesClient.resources(type).inNamespace(namespace).create(resource); + public T create(T resource) { + return kubernetesClient.resource(resource).inNamespace(namespace).create(); } - public T replace(Class type, T resource) { - return kubernetesClient.resources(type).inNamespace(namespace).replace(resource); + public T serverSideApply(T resource) { + return kubernetesClient.resource(resource).inNamespace(namespace).serverSideApply(); } - @SuppressWarnings("unchecked") - public boolean delete(Class type, T resource) { - return kubernetesClient.resources(type).inNamespace(namespace).delete(resource); + 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 = context.getRequiredTestClass().getSimpleName(); - namespace += "-"; - namespace += UUID.randomUUID(); - namespace = KubernetesResourceUtil.sanitizeName(namespace).toLowerCase(Locale.US); - namespace = namespace.substring(0, Math.min(namespace.length(), 63)); - + namespace = perClassNamespaceNameSupplier.apply(context); before(context); } } protected void beforeEachImpl(ExtensionContext context) { if (!oneNamespacePerClass) { - namespace = context.getRequiredTestClass().getSimpleName(); - namespace += "-"; - namespace += context.getRequiredTestMethod().getName(); - namespace += "-"; - namespace += UUID.randomUUID(); - namespace = KubernetesResourceUtil.sanitizeName(namespace).toLowerCase(Locale.US); - namespace = namespace.substring(0, Math.min(namespace.length(), 63)); - + namespace = namespaceNameSupplier.apply(context); before(context); } } @@ -144,11 +143,13 @@ protected void before(ExtensionContext context) { kubernetesClient .namespaces() - .create(new NamespaceBuilder().withNewMetadata().withName(namespace).endMetadata().build()); + .resource( + new NamespaceBuilder() + .withMetadata(new ObjectMetaBuilder().withName(namespace).build()) + .build()) + .serverSideApply(); - kubernetesClient - .resourceList(infrastructure) - .createOrReplace(); + kubernetesClient.resourceList(infrastructure).serverSideApply(); kubernetesClient .resourceList(infrastructure) .waitUntilReady(infrastructureTimeout.toMillis(), TimeUnit.MILLISECONDS); @@ -179,7 +180,7 @@ protected void after(ExtensionContext context) { LOGGER.info("Waiting for namespace {} to be deleted", namespace); Awaitility.await("namespace deleted") .pollInterval(50, TimeUnit.MILLISECONDS) - .atMost(90, TimeUnit.SECONDS) + .atMost(namespaceDeleteTimeout, TimeUnit.SECONDS) .until(() -> kubernetesClient.namespaces().withName(namespace).get() == null); } } @@ -191,31 +192,35 @@ protected void deleteOperator() { } @SuppressWarnings("unchecked") - public static abstract class AbstractBuilder> { - protected ConfigurationService configurationService; + 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.configurationService = new BaseConfigurationService(Version.UNKNOWN); - this.infrastructure = new ArrayList<>(); this.infrastructureTimeout = Duration.ofMinutes(1); - this.preserveNamespaceOnError = Utils.getSystemPropertyOrEnvVar( - "josdk.it.preserveNamespaceOnError", - false); + this.preserveNamespaceOnError = + Utils.getSystemPropertyOrEnvVar("josdk.it.preserveNamespaceOnError", false); + + this.waitForNamespaceDeletion = + Utils.getSystemPropertyOrEnvVar("josdk.it.waitForNamespaceDeletion", true); - this.waitForNamespaceDeletion = Utils.getSystemPropertyOrEnvVar( - "josdk.it.waitForNamespaceDeletion", - true); + this.oneNamespacePerClass = + Utils.getSystemPropertyOrEnvVar("josdk.it.oneNamespacePerClass", false); - this.oneNamespacePerClass = Utils.getSystemPropertyOrEnvVar( - "josdk.it.oneNamespacePerClass", - false); + this.namespaceDeleteTimeout = + Utils.getSystemPropertyOrEnvVar( + "josdk.it.namespaceDeleteTimeout", DEFAULT_NAMESPACE_DELETE_TIMEOUT); } public T preserveNamespaceOnError(boolean value) { @@ -233,8 +238,8 @@ public T oneNamespacePerClass(boolean value) { return (T) this; } - public T withConfigurationService(ConfigurationService value) { - configurationService = value; + public T withConfigurationService(Consumer overrider) { + configurationServiceOverrider = overrider; return (T) this; } @@ -253,5 +258,21 @@ public T withInfrastructure(HasMetadata... 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/E2EOperatorExtension.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/E2EOperatorExtension.java deleted file mode 100644 index 91fecafc2e..0000000000 --- a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/E2EOperatorExtension.java +++ /dev/null @@ -1,135 +0,0 @@ -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 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.javaoperatorsdk.operator.api.config.ConfigurationService; - -public class E2EOperatorExtension extends AbstractOperatorExtension { - - private static final Logger LOGGER = LoggerFactory.getLogger(E2EOperatorExtension.class); - - private final List operatorDeployment; - private final Duration operatorDeploymentTimeout; - - private E2EOperatorExtension( - ConfigurationService configurationService, - List operatorDeployment, - Duration operatorDeploymentTimeout, - List infrastructure, - Duration infrastructureTimeout, - boolean preserveNamespaceOnError, - boolean waitForNamespaceDeletion, - boolean oneNamespacePerClass) { - super(configurationService, infrastructure, infrastructureTimeout, oneNamespacePerClass, - preserveNamespaceOnError, - waitForNamespaceDeletion); - this.operatorDeployment = operatorDeployment; - this.operatorDeploymentTimeout = operatorDeploymentTimeout; - } - - /** - * Creates a {@link Builder} to set up an {@link E2EOperatorExtension} 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(); - crd.waitUntilReady(2, TimeUnit.SECONDS); - LOGGER.debug("Applied CRD with name: {}", crd.get().get(0).getMetadata().getName()); - } catch (Exception ex) { - throw new IllegalStateException("Cannot apply CRD yaml: " + crdFile.getAbsolutePath(), ex); - } - } - - LOGGER.debug("Deploying the operator into Kubernetes"); - 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); - } - - @Override - protected void deleteOperator() { - getKubernetesClient().resourceList(operatorDeployment).inNamespace(namespace).delete(); - } - - public static class Builder extends AbstractBuilder { - private final List operatorDeployment; - private Duration deploymentTimeout; - - protected Builder() { - super(); - this.operatorDeployment = new ArrayList<>(); - this.deploymentTimeout = Duration.ofMinutes(1); - } - - public Builder withDeploymentTimeout(Duration value) { - deploymentTimeout = value; - return this; - } - - public Builder withOperatorDeployment(List hm) { - operatorDeployment.addAll(hm); - return this; - } - - public Builder withOperatorDeployment(HasMetadata... hms) { - operatorDeployment.addAll(Arrays.asList(hms)); - return this; - } - - public E2EOperatorExtension build() { - return new E2EOperatorExtension( - configurationService, - operatorDeployment, - deploymentTimeout, - infrastructure, - infrastructureTimeout, - preserveNamespaceOnError, - waitForNamespaceDeletion, - oneNamespacePerClass); - } - } -} 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/KubernetesClientAware.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/KubernetesClientAware.java deleted file mode 100644 index 8a1a702074..0000000000 --- a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/KubernetesClientAware.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.javaoperatorsdk.operator.junit; - -import io.fabric8.kubernetes.client.KubernetesClient; - -public interface KubernetesClientAware extends HasKubernetesClient { - void setKubernetesClient(KubernetesClient kubernetesClient); -} 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/main/java/io/javaoperatorsdk/operator/junit/OperatorExtension.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/OperatorExtension.java deleted file mode 100644 index 76d848c896..0000000000 --- a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/OperatorExtension.java +++ /dev/null @@ -1,174 +0,0 @@ -package io.javaoperatorsdk.operator.junit; - -import java.io.InputStream; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -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.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; -import io.javaoperatorsdk.operator.api.reconciler.Reconciler; -import io.javaoperatorsdk.operator.processing.Controller; -import io.javaoperatorsdk.operator.processing.retry.Retry; - -import static io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override; - -@SuppressWarnings("rawtypes") -public class OperatorExtension extends AbstractOperatorExtension { - - private static final Logger LOGGER = LoggerFactory.getLogger(OperatorExtension.class); - - private final Operator operator; - private final List reconcilers; - - private OperatorExtension( - ConfigurationService configurationService, - List reconcilers, - List infrastructure, - Duration infrastructureTimeout, - boolean preserveNamespaceOnError, - boolean waitForNamespaceDeletion, - boolean oneNamespacePerClass) { - super(configurationService, infrastructure, infrastructureTimeout, oneNamespacePerClass, - preserveNamespaceOnError, - waitForNamespaceDeletion); - this.reconcilers = reconcilers; - this.operator = new Operator(getKubernetesClient(), this.configurationService); - } - - /** - * Creates a {@link Builder} to set up an {@link OperatorExtension} instance. - * - * @return the builder. - */ - public static Builder builder() { - return new Builder(); - } - - private Stream reconcilers() { - return operator.getControllers().stream().map(Controller::getReconciler); - } - - public List getReconcilers() { - return reconcilers().collect(Collectors.toUnmodifiableList()); - } - - public Reconciler getFirstReconciler() { - return reconcilers().findFirst().orElseThrow(); - } - - public T getControllerOfType(Class type) { - return reconcilers() - .filter(type::isInstance) - .map(type::cast) - .findFirst() - .orElseThrow( - () -> new IllegalArgumentException("Unable to find a reconciler of type: " + type)); - } - - @SuppressWarnings("unchecked") - protected void before(ExtensionContext context) { - super.before(context); - - for (var ref : reconcilers) { - final var config = configurationService.getConfigurationFor(ref.reconciler); - final var oconfig = override(config).settingNamespace(namespace); - final var path = "/META-INF/fabric8/" + config.getResourceTypeName() + "-v1.yml"; - - if (ref.retry != null) { - oconfig.withRetry(ref.retry); - } - - final var kubernetesClient = getKubernetesClient(); - try (InputStream is = getClass().getResourceAsStream(path)) { - final var crd = kubernetesClient.load(is); - crd.createOrReplace(); - crd.waitUntilReady(2, TimeUnit.SECONDS); - LOGGER.debug("Applied CRD with name: {}", config.getResourceTypeName()); - } catch (Exception ex) { - throw new IllegalStateException("Cannot apply CRD yaml: " + path, ex); - } - - if (ref.reconciler instanceof KubernetesClientAware) { - ((KubernetesClientAware) ref.reconciler).setKubernetesClient(kubernetesClient); - } - - this.operator.register(ref.reconciler, oconfig.build()); - } - - LOGGER.debug("Starting the operator locally"); - this.operator.start(); - } - - protected void after(ExtensionContext context) { - super.after(context); - - try { - this.operator.stop(); - } catch (Exception e) { - // ignored - } - } - - @SuppressWarnings("rawtypes") - public static class Builder extends AbstractBuilder { - private final List reconcilers; - - protected Builder() { - super(); - this.reconcilers = new ArrayList<>(); - } - - @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 OperatorExtension build() { - return new OperatorExtension( - configurationService, - reconcilers, - infrastructure, - infrastructureTimeout, - preserveNamespaceOnError, - waitForNamespaceDeletion, - oneNamespacePerClass); - } - } - - @SuppressWarnings("rawtypes") - private static class ReconcilerSpec { - final Reconciler reconciler; - final Retry retry; - - public ReconcilerSpec(Reconciler reconciler, Retry retry) { - this.reconciler = reconciler; - this.retry = retry; - } - } -} 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 0b16c01603..f1a100eb75 100644 --- a/operator-framework/pom.xml +++ b/operator-framework/pom.xml @@ -1,13 +1,11 @@ - + + 4.0.0 - java-operator-sdk io.javaoperatorsdk - 2.1.2-SNAPSHOT + java-operator-sdk + 5.1.5-SNAPSHOT - 4.0.0 operator-framework Operator SDK - Framework - Plain Java @@ -17,26 +15,23 @@ io.javaoperatorsdk operator-framework-core - - - org.apache.commons - commons-lang3 - - org.slf4j - slf4j-api + io.fabric8 + kubernetes-httpclient-${fabric8-httpclient-impl.name} - com.google.auto.service - auto-service - compile + org.apache.commons + commons-lang3 - com.squareup javapoet compile + + org.slf4j + slf4j-api + org.junit.jupiter junit-jupiter-api @@ -62,21 +57,20 @@ compile-testing test + io.fabric8 - crd-generator-apt + openshift-client-api test org.apache.logging.log4j - log4j-slf4j-impl + log4j-slf4j2-impl test org.apache.logging.log4j log4j-core - ${log4j.version} - test-jar test @@ -85,6 +79,65 @@ ${project.version} test + + io.fabric8 + kube-api-test-client-inject + test + - \ No newline at end of file + + + + 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/io/javaoperatorsdk/operator/config/runtime/AccumulativeMappingWriter.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/AccumulativeMappingWriter.java index 6f37572e24..6e70a60f21 100644 --- 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 @@ -56,8 +56,8 @@ public AccumulativeMappingWriter add(String key, String value) { } /** - * Generates or override the resource file with the given path - * ({@link AccumulativeMappingWriter#resourcePath}) + * Generates or override the resource file with the given path ({@link + * AccumulativeMappingWriter#resourcePath}) */ public void flush() { PrintWriter printWriter = null; diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/AnnotationConfiguration.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/AnnotationConfiguration.java deleted file mode 100644 index a1cf34a010..0000000000 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/runtime/AnnotationConfiguration.java +++ /dev/null @@ -1,136 +0,0 @@ -package io.javaoperatorsdk.operator.config.runtime; - -import java.time.Duration; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; - -import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.ReconcilerUtils; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; -import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.Reconciler; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilters; - -public class AnnotationConfiguration - implements io.javaoperatorsdk.operator.api.config.ControllerConfiguration { - - private final Reconciler reconciler; - private final ControllerConfiguration annotation; - private ConfigurationService service; - - public AnnotationConfiguration(Reconciler reconciler) { - this.reconciler = reconciler; - this.annotation = reconciler.getClass().getAnnotation(ControllerConfiguration.class); - } - - @Override - public String getName() { - return ReconcilerUtils.getNameFor(reconciler); - } - - @Override - public String getFinalizer() { - if (annotation == null || annotation.finalizerName().isBlank()) { - return ReconcilerUtils.getDefaultFinalizerName(getResourceClass()); - } else { - final var finalizer = annotation.finalizerName(); - 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"); - } - } - } - - @Override - public boolean isGenerationAware() { - return valueOrDefault(annotation, ControllerConfiguration::generationAwareEventProcessing, - true); - } - - @Override - public Class getResourceClass() { - return RuntimeControllerMetadata.getResourceClass(reconciler); - } - - @Override - public Set getNamespaces() { - return Set.of(valueOrDefault(annotation, ControllerConfiguration::namespaces, new String[] {})); - } - - @Override - public String getLabelSelector() { - return valueOrDefault(annotation, ControllerConfiguration::labelSelector, ""); - } - - @Override - public ConfigurationService getConfigurationService() { - return service; - } - - @Override - public void setConfigurationService(ConfigurationService service) { - this.service = service; - } - - @Override - public String getAssociatedReconcilerClassName() { - return reconciler.getClass().getCanonicalName(); - } - - @SuppressWarnings("unchecked") - @Override - public ResourceEventFilter getEventFilter() { - ResourceEventFilter answer = null; - - Class>[] filterTypes = - (Class>[]) valueOrDefault(annotation, - ControllerConfiguration::eventFilters, - new Object[] {}); - if (filterTypes.length > 0) { - for (var filterType : filterTypes) { - try { - ResourceEventFilter filter = filterType.getConstructor().newInstance(); - - if (answer == null) { - answer = filter; - } else { - answer = answer.and(filter); - } - } catch (Exception e) { - throw new IllegalArgumentException(e); - } - } - } - return answer != null - ? answer - : ResourceEventFilters.passthrough(); - } - - @Override - public Optional reconciliationMaxInterval() { - if (annotation.reconciliationMaxInterval() != null) { - if (annotation.reconciliationMaxInterval().interval() <= 0) { - return Optional.empty(); - } - return Optional.of(Duration.of(annotation.reconciliationMaxInterval().interval(), - annotation.reconciliationMaxInterval().timeUnit().toChronoUnit())); - } else { - return io.javaoperatorsdk.operator.api.config.ControllerConfiguration.super.reconciliationMaxInterval(); - } - } - - public static T valueOrDefault(ControllerConfiguration controllerConfiguration, - Function mapper, - T defaultValue) { - if (controllerConfiguration == null) { - return defaultValue; - } else { - return mapper.apply(controllerConfiguration); - } - } -} - 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 index d3559a9201..0bc2525a86 100644 --- 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 @@ -25,7 +25,7 @@ static Map provide(final String resourcePath, T key, V value) { try { final var classLoader = Thread.currentThread().getContextClassLoader(); final Enumeration resourcesMetadataList = classLoader.getResources(resourcePath); - for (Iterator it = resourcesMetadataList.asIterator(); it.hasNext();) { + for (Iterator it = resourcesMetadataList.asIterator(); it.hasNext(); ) { URL url = it.next(); List classNamePairs = retrieveClassNamePairs(url); @@ -36,8 +36,7 @@ static Map provide(final String resourcePath, T key, V value) { if (classNames.length != 2) { throw new IllegalStateException( String.format( - "%s is not valid Mapping metadata, defined in %s", - clazzPair, url)); + "%s is not valid Mapping metadata, defined in %s", clazzPair, url)); } result.put( @@ -56,8 +55,7 @@ static Map provide(final String resourcePath, T key, V value) { private static List retrieveClassNamePairs(URL url) throws IOException { try (BufferedReader br = new BufferedReader(new InputStreamReader(url.openStream()))) { - return br.lines() - .collect(Collectors.toList()); + 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 index d1cdb28581..a2df87fefb 100644 --- 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 @@ -4,10 +4,8 @@ import javax.annotation.processing.AbstractProcessor; import javax.annotation.processing.ProcessingEnvironment; -import javax.annotation.processing.Processor; import javax.annotation.processing.RoundEnvironment; import javax.annotation.processing.SupportedAnnotationTypes; -import javax.annotation.processing.SupportedSourceVersion; import javax.lang.model.SourceVersion; import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; @@ -21,14 +19,11 @@ import io.fabric8.kubernetes.client.CustomResource; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; -import com.google.auto.service.AutoService; import com.squareup.javapoet.TypeName; import static io.javaoperatorsdk.operator.config.runtime.RuntimeControllerMetadata.RECONCILERS_RESOURCE_PATH; @SupportedAnnotationTypes("io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration") -@SupportedSourceVersion(SourceVersion.RELEASE_11) -@AutoService(Processor.class) public class ControllerConfigurationAnnotationProcessor extends AbstractProcessor { private static final Logger log = @@ -37,6 +32,11 @@ public class ControllerConfigurationAnnotationProcessor extends AbstractProcesso private AccumulativeMappingWriter controllersResourceWriter; private TypeParameterResolver typeParameterResolver; + @Override + public SourceVersion getSupportedSourceVersion() { + return SourceVersion.latest(); + } + @Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); @@ -70,9 +70,7 @@ private TypeParameterResolver initializeResolver(ProcessingEnvironment processin processingEnv .getTypeUtils() .getDeclaredType( - processingEnv - .getElementUtils() - .getTypeElement(Reconciler.class.getCanonicalName()), + processingEnv.getElementUtils().getTypeElement(Reconciler.class.getCanonicalName()), processingEnv.getTypeUtils().getWildcardType(null, null)); return new TypeParameterResolver(resourceControllerType, 0); } 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 index 44fc674028..1a1214aa78 100644 --- 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 @@ -3,52 +3,14 @@ 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.Utils; +import io.javaoperatorsdk.operator.api.config.ResolvedControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; public class DefaultConfigurationService extends BaseConfigurationService { - private static final DefaultConfigurationService instance = new DefaultConfigurationService(); - - private DefaultConfigurationService() { - super(Utils.loadFromProperties()); - } - - public static DefaultConfigurationService instance() { - return instance; - } - - @Override - public ControllerConfiguration getConfigurationFor( - Reconciler reconciler) { - return getConfigurationFor(reconciler, true); - } - - ControllerConfiguration getConfigurationFor( - Reconciler reconciler, boolean createIfNeeded) { - var config = super.getConfigurationFor(reconciler); - if (config == null) { - if (createIfNeeded) { - // create the configuration on demand and register it - config = new AnnotationConfiguration<>(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 - public boolean checkCRDAndValidateLocalModel() { - return Utils.shouldCheckCRDAndValidateLocalModel(); + 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 index 1595f88f97..0fda410406 100644 --- 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 @@ -9,7 +9,8 @@ public class RuntimeControllerMetadata { public static final String RECONCILERS_RESOURCE_PATH = "javaoperatorsdk/reconcilers"; - private static final Map, Class> controllerToCustomResourceMappings; + private static final Map, Class> + controllerToCustomResourceMappings; static { controllerToCustomResourceMappings = @@ -17,8 +18,8 @@ public class RuntimeControllerMetadata { RECONCILERS_RESOURCE_PATH, Reconciler.class, HasMetadata.class); } - static Class getResourceClass( - Reconciler reconciler) { + @SuppressWarnings("unchecked") + static Class getResourceClass(Reconciler reconciler) { final Class resourceClass = controllerToCustomResourceMappings.get(reconciler.getClass()); if (resourceClass == null) { 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 index 82f46dcdf6..ef4af729f1 100644 --- 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 @@ -30,11 +30,11 @@ public TypeParameterResolver(DeclaredType interestedClass, int interestedTypeArg /** * @param typeUtils Type utilities, During the annotation processing processingEnv.getTypeUtils() - * can be passed. + * 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 the given declareType, - * otherwise it returns null + * 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); @@ -138,18 +138,18 @@ private List findChainOfInterfaces(Types typeUtils, DeclaredType p var matchingInterfaces = ((TypeElement) parentInterface.asElement()) .getInterfaces().stream() - .filter(i -> typeUtils.isAssignable(i, interestedClass)) - .map(i -> (DeclaredType) i) - .collect(Collectors.toList()); + .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()); + .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/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/ConcurrencyIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/ConcurrencyIT.java deleted file mode 100644 index 12cf3ca4d8..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/ConcurrencyIT.java +++ /dev/null @@ -1,103 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; -import io.javaoperatorsdk.operator.sample.simple.TestReconciler; -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 - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .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).create(tcr); - } - - 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) - .createOrReplace(tcr); - } - // 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).delete(tcr); - } - - 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/ControllerExecutionIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/ControllerExecutionIT.java deleted file mode 100644 index dbd015587e..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/ControllerExecutionIT.java +++ /dev/null @@ -1,78 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; -import io.javaoperatorsdk.operator.sample.simple.TestReconciler; -import io.javaoperatorsdk.operator.support.TestUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -class ControllerExecutionIT { - @RegisterExtension - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(new TestReconciler(true)) - .build(); - - @Test - void configMapGetsCreatedForTestCustomResource() { - operator.getControllerOfType(TestReconciler.class).setUpdateStatus(true); - - TestCustomResource resource = TestUtils.testCustomResource(); - operator.create(TestCustomResource.class, resource); - - awaitResourcesCreatedOrUpdated(); - awaitStatusUpdated(); - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); - } - - @Test - void eventIsSkippedChangedOnMetadataOnlyUpdate() { - operator.getControllerOfType(TestReconciler.class).setUpdateStatus(false); - - TestCustomResource resource = TestUtils.testCustomResource(); - operator.create(TestCustomResource.class, resource); - - awaitResourcesCreatedOrUpdated(); - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(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/CustomResourceFilterIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/CustomResourceFilterIT.java deleted file mode 100644 index 70a3f04777..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/CustomResourceFilterIT.java +++ /dev/null @@ -1,51 +0,0 @@ -package io.javaoperatorsdk.operator; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -import io.fabric8.kubernetes.api.model.ObjectMeta; -import io.javaoperatorsdk.operator.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.customfilter.CustomFilteringTestReconciler; -import io.javaoperatorsdk.operator.sample.customfilter.CustomFilteringTestResource; -import io.javaoperatorsdk.operator.sample.customfilter.CustomFilteringTestResourceSpec; - -import static org.assertj.core.api.Assertions.assertThat; - -class CustomResourceFilterIT { - - @RegisterExtension - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(new CustomFilteringTestReconciler()) - .build(); - - @Test - void doesCustomFiltering() throws InterruptedException { - var filtered1 = createTestResource("filtered1", true, false); - var filtered2 = createTestResource("filtered2", false, true); - var notFiltered = createTestResource("notfiltered", true, true); - operator.create(CustomFilteringTestResource.class, filtered1); - operator.create(CustomFilteringTestResource.class, filtered2); - operator.create(CustomFilteringTestResource.class, notFiltered); - - Thread.sleep(300); - - assertThat( - ((CustomFilteringTestReconciler) operator.getReconcilers().get(0)).getNumberOfExecutions()) - .isEqualTo(1); - } - - - CustomFilteringTestResource createTestResource(String name, boolean filter1, boolean filter2) { - CustomFilteringTestResource resource = new CustomFilteringTestResource(); - resource.setMetadata(new ObjectMeta()); - resource.getMetadata().setName(name); - resource.setSpec(new CustomFilteringTestResourceSpec()); - resource.getSpec().setFilter1(filter1); - resource.getSpec().setFilter2(filter2); - return resource; - } - -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/ErrorStatusHandlerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/ErrorStatusHandlerIT.java deleted file mode 100644 index fbf54a1ad1..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/ErrorStatusHandlerIT.java +++ /dev/null @@ -1,62 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.processing.retry.GenericRetry; -import io.javaoperatorsdk.operator.sample.errorstatushandler.ErrorStatusHandlerTestCustomResource; -import io.javaoperatorsdk.operator.sample.errorstatushandler.ErrorStatusHandlerTestReconciler; - -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 - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(reconciler, - new GenericRetry().setMaxAttempts(MAX_RETRY_ATTEMPTS).withLinearRetry()) - .build(); - - @Test - void testErrorMessageSetEventually() { - ErrorStatusHandlerTestCustomResource resource = - operator.create(ErrorStatusHandlerTestCustomResource.class, 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/EventSourceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/EventSourceIT.java deleted file mode 100644 index c91ebaad43..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/EventSourceIT.java +++ /dev/null @@ -1,54 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.event.EventSourceTestCustomReconciler; -import io.javaoperatorsdk.operator.sample.event.EventSourceTestCustomResource; -import io.javaoperatorsdk.operator.sample.event.EventSourceTestCustomResourceSpec; -import io.javaoperatorsdk.operator.support.TestUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -class EventSourceIT { - @RegisterExtension - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(EventSourceTestCustomReconciler.class) - .build(); - - @Test - void receivingPeriodicEvents() { - EventSourceTestCustomResource resource = createTestCustomResource("1"); - - operator.create(EventSourceTestCustomResource.class, 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()) - .withFinalizers(EventSourceTestCustomReconciler.FINALIZER_NAME) - .build()); - resource.setSpec(new EventSourceTestCustomResourceSpec()); - resource.getSpec().setValue(id); - return resource; - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/InformerEventSourceIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/InformerEventSourceIT.java deleted file mode 100644 index 7ff36bd375..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/InformerEventSourceIT.java +++ /dev/null @@ -1,98 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.informereventsource.InformerEventSourceTestCustomReconciler; -import io.javaoperatorsdk.operator.sample.informereventsource.InformerEventSourceTestCustomResource; - -import static io.javaoperatorsdk.operator.sample.informereventsource.InformerEventSourceTestCustomReconciler.*; -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 - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(new InformerEventSourceTestCustomReconciler()) - .build(); - - @Test - void testUsingInformerToWatchChangesOfConfigMap() { - var customResource = initialCustomResource(); - customResource = operator.create(InformerEventSourceTestCustomResource.class, customResource); - ConfigMap configMap = - operator.create(ConfigMap.class, relatedConfigMap(customResource.getMetadata().getName())); - waitForCRStatusValue(INITIAL_STATUS_MESSAGE); - - configMap.getData().put(TARGET_CONFIG_MAP_KEY, UPDATE_STATUS_MESSAGE); - configMap = operator.replace(ConfigMap.class, configMap); - - waitForCRStatusValue(UPDATE_STATUS_MESSAGE); - } - - @Test - void deletingSecondaryResource() { - var customResource = initialCustomResource(); - customResource = operator.create(InformerEventSourceTestCustomResource.class, customResource); - ConfigMap configMap = - operator.create(ConfigMap.class, relatedConfigMap(customResource.getMetadata().getName())); - waitForCRStatusValue(INITIAL_STATUS_MESSAGE); - - boolean res = operator.delete(ConfigMap.class, 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/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/KubernetesResourceStatusUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/KubernetesResourceStatusUpdateIT.java deleted file mode 100644 index a2c7378ff5..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/KubernetesResourceStatusUpdateIT.java +++ /dev/null @@ -1,79 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.*; -import io.fabric8.kubernetes.api.model.apps.Deployment; -import io.fabric8.kubernetes.api.model.apps.DeploymentSpec; -import io.javaoperatorsdk.operator.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.deployment.DeploymentReconciler; - -import static io.javaoperatorsdk.operator.sample.deployment.DeploymentReconciler.STATUS_MESSAGE; -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -class KubernetesResourceStatusUpdateIT { - - @RegisterExtension - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(new DeploymentReconciler()) - .build(); - - @Test - void testReconciliationOfNonCustomResourceAndStatusUpdate() { - var deployment = operator.create(Deployment.class, 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/MaxIntervalIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/MaxIntervalIT.java deleted file mode 100644 index f284dbf407..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/MaxIntervalIT.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.maxinterval.MaxIntervalTestCustomResource; -import io.javaoperatorsdk.operator.sample.maxinterval.MaxIntervalTestReconciler; - -import static org.awaitility.Awaitility.await; - -class MaxIntervalIT { - - @RegisterExtension - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(new MaxIntervalTestReconciler()) - .build(); - - @Test - void reconciliationTriggeredBasedOnMaxInterval() { - MaxIntervalTestCustomResource cr = createTestResource(); - - operator.create(MaxIntervalTestCustomResource.class, cr); - - await() - .pollInterval(50, TimeUnit.MILLISECONDS) - .atMost(500, TimeUnit.MILLISECONDS) - .until( - () -> ((MaxIntervalTestReconciler) operator.getFirstReconciler()) - .getNumberOfExecutions() > 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/MultiVersionCRDIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/MultiVersionCRDIT.java deleted file mode 100644 index 930fc210b3..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/MultiVersionCRDIT.java +++ /dev/null @@ -1,96 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.ObjectMeta; -import io.javaoperatorsdk.operator.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.multiversioncrd.*; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.awaitility.Awaitility.await; - -class MultiVersionCRDIT { - - public static final String CR_V1_NAME = "crv1"; - public static final String CR_V2_NAME = "crv2"; - - @RegisterExtension - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(MultiVersionCRDTestReconciler1.class) - .withReconciler(MultiVersionCRDTestReconciler2.class) - .build(); - - @Test - void multipleCRDVersions() { - operator.create(MultiVersionCRDTestCustomResource1.class, createTestResourceV1WithoutLabel()); - operator.create(MultiVersionCRDTestCustomResource2.class, createTestResourceV2WithLabel()); - - await() - .atMost(Duration.ofSeconds(2)) - .pollInterval(Duration.ofMillis(50)) - .until( - () -> { - var crV1Now = operator.get(MultiVersionCRDTestCustomResource1.class, CR_V1_NAME); - var crV2Now = operator.get(MultiVersionCRDTestCustomResource2.class, CR_V2_NAME); - return crV1Now.getStatus().getReconciledBy().size() == 1 - && crV1Now.getStatus().getReconciledBy() - .contains(MultiVersionCRDTestReconciler1.class.getSimpleName()) - && crV2Now.getStatus().getReconciledBy().size() == 1 - && crV2Now.getStatus().getReconciledBy() - .contains(MultiVersionCRDTestReconciler2.class.getSimpleName()); - }); - } - - @Test - void invalidEventsDoesNotBreakEventHandling() { - var v2res = createTestResourceV2WithLabel(); - v2res.getMetadata().getLabels().clear(); - operator.create(MultiVersionCRDTestCustomResource2.class, v2res); - var v1res = createTestResourceV1WithoutLabel(); - operator.create(MultiVersionCRDTestCustomResource1.class, v1res); - - await() - .atMost(Duration.ofSeconds(2)) - .pollInterval(Duration.ofMillis(50)) - .until(() -> { - var crV1Now = operator.get(MultiVersionCRDTestCustomResource1.class, CR_V1_NAME); - return crV1Now.getStatus().getReconciledBy() - .contains(MultiVersionCRDTestReconciler1.class.getSimpleName()); - }); - assertThat( - operator - .get(MultiVersionCRDTestCustomResource2.class, CR_V2_NAME) - .getStatus() - .getReconciledBy() - .size()) - .isZero(); - } - - - 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/ObservedGenerationHandlingIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/ObservedGenerationHandlingIT.java deleted file mode 100644 index 5f86336a1c..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/ObservedGenerationHandlingIT.java +++ /dev/null @@ -1,40 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.observedgeneration.ObservedGenerationTestCustomResource; -import io.javaoperatorsdk.operator.sample.observedgeneration.ObservedGenerationTestReconciler; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -class ObservedGenerationHandlingIT { - @RegisterExtension - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(new ObservedGenerationTestReconciler()) - .build(); - - @Test - void testReconciliationOfNonCustomResourceAndStatusUpdate() { - var resource = new ObservedGenerationTestCustomResource(); - resource.setMetadata(new ObjectMeta()); - resource.getMetadata().setName("observed-gen1"); - - var createdResource = operator.create(ObservedGenerationTestCustomResource.class, resource); - - await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { - var d = operator.get(ObservedGenerationTestCustomResource.class, - createdResource.getMetadata().getName()); - assertThat(d.getStatus().getObservedGeneration()).isNotNull(); - assertThat(d.getStatus().getObservedGeneration()).isEqualTo(1); - }); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/RetryIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/RetryIT.java deleted file mode 100644 index c4ab025ac5..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/RetryIT.java +++ /dev/null @@ -1,77 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.processing.retry.GenericRetry; -import io.javaoperatorsdk.operator.sample.retry.RetryTestCustomReconciler; -import io.javaoperatorsdk.operator.sample.retry.RetryTestCustomResource; -import io.javaoperatorsdk.operator.sample.retry.RetryTestCustomResourceSpec; -import io.javaoperatorsdk.operator.sample.retry.RetryTestCustomResourceStatus; -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 - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .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(RetryTestCustomResource.class, 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) - .withFinalizers(RetryTestCustomReconciler.FINALIZER_NAME) - .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/RetryMaxAttemptIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/RetryMaxAttemptIT.java deleted file mode 100644 index 5ebb05eb0c..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/RetryMaxAttemptIT.java +++ /dev/null @@ -1,42 +0,0 @@ -package io.javaoperatorsdk.operator; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -import io.javaoperatorsdk.operator.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.processing.retry.GenericRetry; -import io.javaoperatorsdk.operator.sample.retry.RetryTestCustomReconciler; -import io.javaoperatorsdk.operator.sample.retry.RetryTestCustomResource; - -import static io.javaoperatorsdk.operator.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 - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .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(RetryTestCustomResource.class, 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/SubResourceUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/SubResourceUpdateIT.java deleted file mode 100644 index 75597643bf..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/SubResourceUpdateIT.java +++ /dev/null @@ -1,131 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.subresource.SubResourceTestCustomReconciler; -import io.javaoperatorsdk.operator.sample.subresource.SubResourceTestCustomResource; -import io.javaoperatorsdk.operator.sample.subresource.SubResourceTestCustomResourceSpec; -import io.javaoperatorsdk.operator.support.TestUtils; - -import static io.javaoperatorsdk.operator.sample.subresource.SubResourceTestCustomResourceStatus.State.SUCCESS; -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -class SubResourceUpdateIT { - - @RegisterExtension - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(SubResourceTestCustomReconciler.class) - .build(); - - @Test - void updatesSubResourceStatus() { - SubResourceTestCustomResource resource = createTestCustomResource("1"); - operator.create(SubResourceTestCustomResource.class, resource); - - awaitStatusUpdated(resource.getMetadata().getName()); - // wait for sure, there are no more events - waitXms(200); - // 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(SubResourceTestCustomResource.class, resource); - - awaitStatusUpdated(resource.getMetadata().getName()); - // wait for sure, there are no more events - waitXms(200); - // 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(SubResourceTestCustomResource.class, resource); - - awaitStatusUpdated(resource.getMetadata().getName()); - // wait for sure, there are no more events - waitXms(200); - // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)) - .isEqualTo(2); - } - - /** - * Not that here status sub-resource update will fail on optimistic locking. This solves a tricky - * situation: If this would not happen (no optimistic locking on status sub-resource) we could - * receive and store an event while processing the controller method. But this event would always - * fail since its resource version is outdated already. - */ - @Test - void updateCustomResourceAfterSubResourceChange() { - SubResourceTestCustomResource resource = createTestCustomResource("1"); - operator.create(SubResourceTestCustomResource.class, resource); - - resource.getSpec().setValue("new value"); - operator.resources(SubResourceTestCustomResource.class).createOrReplace(resource); - - awaitStatusUpdated(resource.getMetadata().getName()); - - // wait for sure, there are no more events - waitXms(500); - // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)) - .isEqualTo(3); - } - - void awaitStatusUpdated(String name) { - await("cr status updated") - .atMost(5, TimeUnit.SECONDS) - .untilAsserted( - () -> { - SubResourceTestCustomResource cr = - operator.get(SubResourceTestCustomResource.class, name); - assertThat(cr.getMetadata().getFinalizers()).hasSize(1); - 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) - .withFinalizers(SubResourceTestCustomReconciler.FINALIZER_NAME) - .build()); - resource.setKind("SubresourceSample"); - resource.setSpec(new SubResourceTestCustomResourceSpec()); - resource.getSpec().setValue(id); - return resource; - } - - private 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/UpdatingResAndSubResIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/UpdatingResAndSubResIT.java deleted file mode 100644 index 6bad954400..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/UpdatingResAndSubResIT.java +++ /dev/null @@ -1,80 +0,0 @@ -package io.javaoperatorsdk.operator; - -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.config.runtime.DefaultConfigurationService; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.doubleupdate.DoubleUpdateTestCustomReconciler; -import io.javaoperatorsdk.operator.sample.doubleupdate.DoubleUpdateTestCustomResource; -import io.javaoperatorsdk.operator.sample.doubleupdate.DoubleUpdateTestCustomResourceSpec; -import io.javaoperatorsdk.operator.sample.doubleupdate.DoubleUpdateTestCustomResourceStatus; -import io.javaoperatorsdk.operator.support.TestUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -class UpdatingResAndSubResIT { - @RegisterExtension - OperatorExtension operator = - OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(DoubleUpdateTestCustomReconciler.class) - .build(); - - @Test - void updatesSubResourceStatus() { - DoubleUpdateTestCustomResource resource = createTestCustomResource("1"); - operator.create(DoubleUpdateTestCustomResource.class, resource); - - awaitStatusUpdated(resource.getMetadata().getName()); - // wait for sure, there are no more events - TestUtils.waitXms(300); - - DoubleUpdateTestCustomResource customResource = - operator - .get(DoubleUpdateTestCustomResource.class, - resource.getMetadata().getName()); - - assertThat(TestUtils.getNumberOfExecutions(operator)) - .isEqualTo(1); - assertThat(customResource.getStatus().getState()) - .isEqualTo(DoubleUpdateTestCustomResourceStatus.State.SUCCESS); - assertThat( - customResource - .getMetadata() - .getAnnotations() - .get(DoubleUpdateTestCustomReconciler.TEST_ANNOTATION)) - .isNotNull(); - } - - void awaitStatusUpdated(String name) { - await("cr status updated") - .atMost(5, TimeUnit.SECONDS) - .untilAsserted( - () -> { - DoubleUpdateTestCustomResource cr = - operator.get(DoubleUpdateTestCustomResource.class, name); - - assertThat(cr) - .isNotNull(); - assertThat(cr.getMetadata().getFinalizers()) - .hasSize(1); - assertThat(cr.getStatus()) - .isNotNull(); - assertThat(cr.getStatus().getState()) - .isEqualTo(DoubleUpdateTestCustomResourceStatus.State.SUCCESS); - }); - } - - public DoubleUpdateTestCustomResource createTestCustomResource(String id) { - DoubleUpdateTestCustomResource resource = new DoubleUpdateTestCustomResource(); - resource.setMetadata(new ObjectMetaBuilder().withName("doubleupdateresource-" + id).build()); - resource.setSpec(new DoubleUpdateTestCustomResourceSpec()); - resource.getSpec().setValue(id); - return resource; - } -} 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/sample/errorstatushandler/ErrorStatusHandlerTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResource.java similarity index 85% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/errorstatushandler/ErrorStatusHandlerTestCustomResource.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResource.java index d6056ea486..2e4664a5de 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/errorstatushandler/ErrorStatusHandlerTestCustomResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResource.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.errorstatushandler; +package io.javaoperatorsdk.operator.baseapi.errorstatushandler; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; @@ -13,5 +13,4 @@ @ShortNames("esh") public class ErrorStatusHandlerTestCustomResource extends CustomResource - implements Namespaced { -} + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/errorstatushandler/ErrorStatusHandlerTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResourceStatus.java similarity index 86% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/errorstatushandler/ErrorStatusHandlerTestCustomResourceStatus.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResourceStatus.java index 4e54c877a5..42fe4b4b34 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/errorstatushandler/ErrorStatusHandlerTestCustomResourceStatus.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/errorstatushandler/ErrorStatusHandlerTestCustomResourceStatus.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.errorstatushandler; +package io.javaoperatorsdk.operator.baseapi.errorstatushandler; import java.util.ArrayList; import java.util.List; 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/sample/event/EventSourceTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResource.java similarity index 87% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/event/EventSourceTestCustomResource.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResource.java index 1a22ec4f8d..b5f50df476 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/event/EventSourceTestCustomResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResource.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.event; +package io.javaoperatorsdk.operator.baseapi.event; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; @@ -13,5 +13,4 @@ @ShortNames("es") public class EventSourceTestCustomResource extends CustomResource - implements Namespaced { -} + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/event/EventSourceTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceSpec.java similarity index 82% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/event/EventSourceTestCustomResourceSpec.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceSpec.java index d22f34ce97..203fd21440 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/event/EventSourceTestCustomResourceSpec.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceSpec.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.event; +package io.javaoperatorsdk.operator.baseapi.event; public class EventSourceTestCustomResourceSpec { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/event/EventSourceTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceStatus.java similarity index 78% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/event/EventSourceTestCustomResourceStatus.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceStatus.java index 581b4dd74e..c602dd5db4 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/event/EventSourceTestCustomResourceStatus.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/event/EventSourceTestCustomResourceStatus.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.event; +package io.javaoperatorsdk.operator.baseapi.event; public class EventSourceTestCustomResourceStatus { @@ -14,6 +14,7 @@ public EventSourceTestCustomResourceStatus setState(State state) { } public enum State { - SUCCESS, ERROR + 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/sample/informereventsource/InformerEventSourceTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomResourceStatus.java similarity index 83% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/informereventsource/InformerEventSourceTestCustomResourceStatus.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomResourceStatus.java index b4b6b93958..529b53db13 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/informereventsource/InformerEventSourceTestCustomResourceStatus.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/informereventsource/InformerEventSourceTestCustomResourceStatus.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.informereventsource; +package io.javaoperatorsdk.operator.baseapi.informereventsource; public class InformerEventSourceTestCustomResourceStatus { 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/sample/multiversioncrd/MultiVersionCRDTestCustomResourceSpec1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec1.java similarity index 80% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResourceSpec1.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec1.java index 5c915179e2..d870d4d315 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResourceSpec1.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec1.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.multiversioncrd; +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; public class MultiVersionCRDTestCustomResourceSpec1 { @@ -12,5 +12,4 @@ public MultiVersionCRDTestCustomResourceSpec1 setValue(int value) { this.value = value; return this; } - } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResourceSpec2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec2.java similarity index 80% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResourceSpec2.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec2.java index a4058dbd9f..2219acca36 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResourceSpec2.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceSpec2.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.multiversioncrd; +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; public class MultiVersionCRDTestCustomResourceSpec2 { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResourceStatus1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus1.java similarity index 90% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResourceStatus1.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus1.java index 765766b1e2..17c0f00bab 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResourceStatus1.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus1.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.multiversioncrd; +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; import java.util.ArrayList; import java.util.List; diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResourceStatus2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus2.java similarity index 90% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResourceStatus2.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus2.java index 5df57ef76d..5af2e55177 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResourceStatus2.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/multiversioncrd/MultiVersionCRDTestCustomResourceStatus2.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.multiversioncrd; +package io.javaoperatorsdk.operator.baseapi.multiversioncrd; import java.util.ArrayList; import java.util.List; 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/sample/retry/RetryTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResource.java similarity index 86% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/retry/RetryTestCustomResource.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResource.java index 093bdbc6d8..492d8b77bb 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/retry/RetryTestCustomResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResource.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.retry; +package io.javaoperatorsdk.operator.baseapi.retry; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; @@ -13,5 +13,4 @@ @ShortNames("rs") public class RetryTestCustomResource extends CustomResource - implements Namespaced { -} + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/retry/RetryTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceSpec.java similarity index 81% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/retry/RetryTestCustomResourceSpec.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceSpec.java index ec34d11df8..169233e7d4 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/retry/RetryTestCustomResourceSpec.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceSpec.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.retry; +package io.javaoperatorsdk.operator.baseapi.retry; public class RetryTestCustomResourceSpec { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/retry/RetryTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceStatus.java similarity index 77% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/retry/RetryTestCustomResourceStatus.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceStatus.java index d83b611f65..363195c34f 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/retry/RetryTestCustomResourceStatus.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomResourceStatus.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.retry; +package io.javaoperatorsdk.operator.baseapi.retry; public class RetryTestCustomResourceStatus { @@ -14,6 +14,7 @@ public RetryTestCustomResourceStatus setState(State state) { } public enum State { - SUCCESS, ERROR + 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/sample/simple/TestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResource.java similarity index 86% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResource.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResource.java index c72b0533d9..2e2e53b948 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResource.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.simple; +package io.javaoperatorsdk.operator.baseapi.simple; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; @@ -13,5 +13,4 @@ @ShortNames("cs") public class TestCustomResource extends CustomResource - implements Namespaced { -} + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceSpec.java similarity index 93% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceSpec.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceSpec.java index 5fd9f49084..eda3c477b2 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceSpec.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceSpec.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.simple; +package io.javaoperatorsdk.operator.baseapi.simple; public class TestCustomResourceSpec { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceStatus.java similarity index 88% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceStatus.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceStatus.java index 620bbaabd8..75fadc8e5e 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestCustomResourceStatus.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/TestCustomResourceStatus.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.simple; +package io.javaoperatorsdk.operator.baseapi.simple; public class TestCustomResourceStatus { 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/sample/subresource/SubResourceTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResource.java similarity index 88% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/subresource/SubResourceTestCustomResource.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResource.java index 5b96483729..0bd59fc7fe 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/subresource/SubResourceTestCustomResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResource.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.subresource; +package io.javaoperatorsdk.operator.baseapi.subresource; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; @@ -15,5 +15,4 @@ @ShortNames("ss") public class SubResourceTestCustomResource extends CustomResource - implements Namespaced { -} + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/subresource/SubResourceTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceSpec.java similarity index 81% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/subresource/SubResourceTestCustomResourceSpec.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceSpec.java index 21b8e2bc84..9b6c4f8060 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/subresource/SubResourceTestCustomResourceSpec.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceSpec.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.subresource; +package io.javaoperatorsdk.operator.baseapi.subresource; public class SubResourceTestCustomResourceSpec { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/subresource/SubResourceTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceStatus.java similarity index 77% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/subresource/SubResourceTestCustomResourceStatus.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceStatus.java index c1c9d708c0..d427e432cd 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/subresource/SubResourceTestCustomResourceStatus.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomResourceStatus.java @@ -1,4 +1,4 @@ -package io.javaoperatorsdk.operator.sample.subresource; +package io.javaoperatorsdk.operator.baseapi.subresource; public class SubResourceTestCustomResourceStatus { @@ -14,6 +14,7 @@ public SubResourceTestCustomResourceStatus setState(State state) { } public enum State { - SUCCESS, ERROR + 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 index ce9637af9e..a7365c19b9 100644 --- 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 @@ -33,7 +33,7 @@ public void generateCorrectDoneableClassIfThereIsAbstractBaseController() { } @Test - public void generateDoneableClasswithMultilevelHierarchy() { + public void generateDoneableClassWithMultilevelHierarchy() { Compilation compilation = Compiler.javac() .withProcessors(new ControllerConfigurationAnnotationProcessor()) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java index bce957a399..5a9599840f 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/runtime/DefaultConfigurationServiceTest.java @@ -1,11 +1,5 @@ package io.javaoperatorsdk.operator.config.runtime; -import org.apache.logging.log4j.Level; -import org.apache.logging.log4j.core.LoggerContext; -import org.apache.logging.log4j.core.config.AppenderRef; -import org.apache.logging.log4j.core.config.LoggerConfig; -import org.apache.logging.log4j.core.layout.PatternLayout; -import org.apache.logging.log4j.test.appender.ListAppender; import org.junit.jupiter.api.Test; import io.fabric8.kubernetes.client.CustomResource; @@ -17,71 +11,22 @@ import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.*; class DefaultConfigurationServiceTest { public static final String CUSTOM_FINALIZER_NAME = "a.custom/finalizer"; - - @Test - void attemptingToRetrieveAnUnknownControllerShouldLogWarning() { - final var configurationService = DefaultConfigurationService.instance(); - - final LoggerContext context = LoggerContext.getContext(false); - final PatternLayout layout = PatternLayout.createDefaultLayout(context.getConfiguration()); - final ListAppender appender = new ListAppender("list", null, layout, false, false); - - appender.start(); - - context.getConfiguration().addAppender(appender); - - AppenderRef ref = AppenderRef.createAppenderRef("list", null, null); - final var loggerName = configurationService.getLoggerName(); - LoggerConfig loggerConfig = - LoggerConfig.createLogger( - false, - Level.valueOf("info"), - loggerName, - "false", - new AppenderRef[] {ref}, - null, - context.getConfiguration(), - null); - loggerConfig.addAppender(appender, null, null); - - context.getConfiguration().addLogger(loggerName, loggerConfig); - context.updateLoggers(); - - try { - final var config = - configurationService - .getConfigurationFor(new NotAutomaticallyCreated(), false); - - assertThat(config).isNull(); - assertThat(appender.getMessages()) - .hasSize(1) - .allMatch(m -> m.contains(NotAutomaticallyCreated.NAME) && m.contains("not found")); - } finally { - appender.stop(); - - context.getConfiguration().removeLogger(loggerName); - context.updateLoggers(); - } - } + final DefaultConfigurationService configurationService = new DefaultConfigurationService(); @Test void returnsValuesFromControllerAnnotationFinalizer() { final var reconciler = new TestCustomReconciler(); - final var configuration = - DefaultConfigurationService.instance().getConfigurationFor(reconciler); - assertEquals(CustomResource.getCRDName(TestCustomResource.class), - configuration.getResourceTypeName()); + final var configuration = configurationService.getConfigurationFor(reconciler); + assertEquals( + CustomResource.getCRDName(TestCustomResource.class), configuration.getResourceTypeName()); assertEquals( ReconcilerUtils.getDefaultFinalizerName(TestCustomResource.class), - configuration.getFinalizer()); + configuration.getFinalizerName()); assertEquals(TestCustomResource.class, configuration.getResourceClass()); assertFalse(configuration.isGenerationAware()); } @@ -89,9 +34,8 @@ void returnsValuesFromControllerAnnotationFinalizer() { @Test void returnCustomerFinalizerNameIfSet() { final var reconciler = new TestCustomFinalizerReconciler(); - final var configuration = - DefaultConfigurationService.instance().getConfigurationFor(reconciler); - assertEquals(CUSTOM_FINALIZER_NAME, configuration.getFinalizer()); + final var configuration = configurationService.getConfigurationFor(reconciler); + assertEquals(CUSTOM_FINALIZER_NAME, configuration.getFinalizerName()); } @Test @@ -99,9 +43,7 @@ void supportsInnerClassCustomResources() { final var reconciler = new TestCustomFinalizerReconciler(); assertDoesNotThrow( () -> { - DefaultConfigurationService.instance() - .getConfigurationFor(reconciler) - .getAssociatedReconcilerClassName(); + configurationService.getConfigurationFor(reconciler).getAssociatedReconcilerClassName(); }); } @@ -111,14 +53,13 @@ static class TestCustomFinalizerReconciler @Override public UpdateControl reconcile( - InnerCustomResource resource, Context context) { + InnerCustomResource resource, Context context) { return null; } @Group("test.crd") @Version("v1") - public static class InnerCustomResource extends CustomResource { - } + public static class InnerCustomResource extends CustomResource {} } @ControllerConfiguration(name = NotAutomaticallyCreated.NAME) @@ -128,7 +69,7 @@ static class NotAutomaticallyCreated implements Reconciler { @Override public UpdateControl reconcile( - TestCustomResource resource, Context context) { + TestCustomResource resource, Context context) { return null; } } @@ -138,7 +79,7 @@ static class TestCustomReconciler implements Reconciler { @Override public UpdateControl reconcile( - TestCustomResource resource, Context context) { + 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 index 0f94ae92e4..14956f470d 100644 --- 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 @@ -7,5 +7,4 @@ @Group("sample.javaoperatorsdk") @Version("v1") -class TestCustomResource extends CustomResource implements Namespaced { -} +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/sample/customfilter/CustomFilteringTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFilteringTestReconciler.java deleted file mode 100644 index 2a13dc3092..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFilteringTestReconciler.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.javaoperatorsdk.operator.sample.customfilter; - -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(eventFilters = {CustomFlagFilter.class, CustomFlagFilter2.class}) -public class CustomFilteringTestReconciler implements Reconciler { - - private final AtomicInteger numberOfExecutions = new AtomicInteger(0); - - @Override - public UpdateControl reconcile(CustomFilteringTestResource resource, - Context context) { - numberOfExecutions.incrementAndGet(); - return UpdateControl.noUpdate(); - } - - public int getNumberOfExecutions() { - return numberOfExecutions.get(); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFilteringTestResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFilteringTestResource.java deleted file mode 100644 index dec7b6c40a..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFilteringTestResource.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.javaoperatorsdk.operator.sample.customfilter; - -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("cft") -public class CustomFilteringTestResource - extends CustomResource - implements Namespaced { -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFilteringTestResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFilteringTestResourceSpec.java deleted file mode 100644 index 8bb1f48054..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFilteringTestResourceSpec.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.javaoperatorsdk.operator.sample.customfilter; - -public class CustomFilteringTestResourceSpec { - - private boolean filter1; - - private boolean filter2; - - public boolean isFilter1() { - return filter1; - } - - public CustomFilteringTestResourceSpec setFilter1(boolean filter1) { - this.filter1 = filter1; - return this; - } - - public boolean isFilter2() { - return filter2; - } - - public CustomFilteringTestResourceSpec setFilter2(boolean filter2) { - this.filter2 = filter2; - return this; - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFlagFilter.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFlagFilter.java deleted file mode 100644 index 58d2ed1ede..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFlagFilter.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.javaoperatorsdk.operator.sample.customfilter; - -import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter; - -public class CustomFlagFilter implements ResourceEventFilter { - - @Override - public boolean acceptChange(ControllerConfiguration configuration, - CustomFilteringTestResource oldResource, CustomFilteringTestResource newResource) { - return newResource.getSpec().isFilter1(); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFlagFilter2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFlagFilter2.java deleted file mode 100644 index f38a8c9553..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/customfilter/CustomFlagFilter2.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.javaoperatorsdk.operator.sample.customfilter; - -import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEventFilter; - -public class CustomFlagFilter2 implements ResourceEventFilter { - - @Override - public boolean acceptChange(ControllerConfiguration configuration, - CustomFilteringTestResource oldResource, CustomFilteringTestResource newResource) { - return newResource.getSpec().isFilter2(); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/deployment/DeploymentReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/deployment/DeploymentReconciler.java deleted file mode 100644 index 3992920884..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/deployment/DeploymentReconciler.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.javaoperatorsdk.operator.sample.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.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(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.updateStatus(resource); - } else { - return UpdateControl.noUpdate(); - } - } - - - @Override - public int getNumberOfExecutions() { - return numberOfExecutions.get(); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomReconciler.java deleted file mode 100644 index bd398d0a1c..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomReconciler.java +++ /dev/null @@ -1,51 +0,0 @@ -package io.javaoperatorsdk.operator.sample.doubleupdate; - -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 DoubleUpdateTestCustomReconciler - implements Reconciler, TestExecutionInfoProvider { - - private static final Logger log = - LoggerFactory.getLogger(DoubleUpdateTestCustomReconciler.class); - public static final String TEST_ANNOTATION = "TestAnnotation"; - public static final String TEST_ANNOTATION_VALUE = "TestAnnotationValue"; - private final AtomicInteger numberOfExecutions = new AtomicInteger(0); - - @Override - public UpdateControl reconcile( - DoubleUpdateTestCustomResource resource, Context context) { - numberOfExecutions.addAndGet(1); - - log.info("Value: " + resource.getSpec().getValue()); - - resource.getMetadata().setAnnotations(new HashMap<>()); - resource.getMetadata().getAnnotations().put(TEST_ANNOTATION, TEST_ANNOTATION_VALUE); - ensureStatusExists(resource); - resource.getStatus().setState(DoubleUpdateTestCustomResourceStatus.State.SUCCESS); - - return UpdateControl.updateResourceAndStatus(resource); - } - - private void ensureStatusExists(DoubleUpdateTestCustomResource resource) { - DoubleUpdateTestCustomResourceStatus status = resource.getStatus(); - if (status == null) { - status = new DoubleUpdateTestCustomResourceStatus(); - resource.setStatus(status); - } - } - - public int getNumberOfExecutions() { - return numberOfExecutions.get(); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomResource.java deleted file mode 100644 index 11c543e388..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomResource.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.javaoperatorsdk.operator.sample.doubleupdate; - -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 DoubleUpdateTestCustomResource - extends CustomResource - implements Namespaced { -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomResourceSpec.java deleted file mode 100644 index 02212957a9..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomResourceSpec.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.javaoperatorsdk.operator.sample.doubleupdate; - -public class DoubleUpdateTestCustomResourceSpec { - - private String value; - - public String getValue() { - return value; - } - - public DoubleUpdateTestCustomResourceSpec setValue(String value) { - this.value = value; - return this; - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomResourceStatus.java deleted file mode 100644 index 3c7b694853..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/doubleupdate/DoubleUpdateTestCustomResourceStatus.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.javaoperatorsdk.operator.sample.doubleupdate; - -public class DoubleUpdateTestCustomResourceStatus { - - private State state; - - public State getState() { - return state; - } - - public DoubleUpdateTestCustomResourceStatus setState(State state) { - this.state = state; - return this; - } - - public enum State { - SUCCESS, ERROR - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/errorstatushandler/ErrorStatusHandlerTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/errorstatushandler/ErrorStatusHandlerTestReconciler.java deleted file mode 100644 index 0ce67d30c3..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/errorstatushandler/ErrorStatusHandlerTestReconciler.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.javaoperatorsdk.operator.sample.errorstatushandler; - -import java.util.Optional; -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; - -import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_FINALIZER; - -@ControllerConfiguration(finalizerName = NO_FINALIZER) -public class ErrorStatusHandlerTestReconciler - implements Reconciler, TestExecutionInfoProvider, - ErrorStatusHandler { - - 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 Optional updateErrorStatus( - ErrorStatusHandlerTestCustomResource resource, RetryInfo retryInfo, RuntimeException e) { - log.info("Setting status."); - ensureStatusExists(resource); - resource.getStatus().getMessages().add(ERROR_STATUS_MESSAGE + retryInfo.getAttemptCount()); - return Optional.of(resource); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/event/EventSourceTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/event/EventSourceTestCustomReconciler.java deleted file mode 100644 index 55eb61634f..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/event/EventSourceTestCustomReconciler.java +++ /dev/null @@ -1,42 +0,0 @@ -package io.javaoperatorsdk.operator.sample.event; - -import java.util.concurrent.atomic.AtomicInteger; - -import io.javaoperatorsdk.operator.ReconcilerUtils; -import io.javaoperatorsdk.operator.api.reconciler.*; -import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; - -@ControllerConfiguration -public class EventSourceTestCustomReconciler - implements Reconciler, - TestExecutionInfoProvider { - - public static final String FINALIZER_NAME = - ReconcilerUtils.getDefaultFinalizerName(EventSourceTestCustomResource.class); - 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.updateStatus(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/sample/informereventsource/InformerEventSourceTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/informereventsource/InformerEventSourceTestCustomReconciler.java deleted file mode 100644 index b7ceb4c11c..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/informereventsource/InformerEventSourceTestCustomReconciler.java +++ /dev/null @@ -1,85 +0,0 @@ -package io.javaoperatorsdk.operator.sample.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.fabric8.kubernetes.client.KubernetesClient; -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.EventSourceInitializer; -import io.javaoperatorsdk.operator.api.reconciler.Reconciler; -import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; -import io.javaoperatorsdk.operator.junit.KubernetesClientAware; -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; - -import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_FINALIZER; - -/** - * Copies the config map value from spec into status. The main purpose is to test and demonstrate - * sample usage of InformerEventSource - */ -@ControllerConfiguration(finalizerName = NO_FINALIZER) -public class InformerEventSourceTestCustomReconciler implements - Reconciler, KubernetesClientAware, - EventSourceInitializer { - - private static final Logger LOGGER = - LoggerFactory.getLogger(InformerEventSourceTestCustomReconciler.class); - - public static final String RELATED_RESOURCE_NAME = "relatedResourceName"; - public static final String TARGET_CONFIG_MAP_KEY = "targetStatus"; - public static final String MISSING_CONFIG_MAP = "Missing Config Map"; - - private KubernetesClient kubernetesClient; - private final AtomicInteger numberOfExecutions = new AtomicInteger(0); - - @Override - public List prepareEventSources( - EventSourceContext context) { - return List.of(new InformerEventSource<>(kubernetesClient, ConfigMap.class, - Mappers.fromAnnotation(RELATED_RESOURCE_NAME))); - } - - @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.updateStatus(resource); - } - - @Override - public KubernetesClient getKubernetesClient() { - return kubernetesClient; - } - - @Override - public void setKubernetesClient(KubernetesClient kubernetesClient) { - this.kubernetesClient = kubernetesClient; - } - - public int getNumberOfExecutions() { - return numberOfExecutions.get(); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/informereventsource/InformerEventSourceTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/informereventsource/InformerEventSourceTestCustomResource.java deleted file mode 100644 index ff1c6758bb..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/informereventsource/InformerEventSourceTestCustomResource.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.javaoperatorsdk.operator.sample.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/sample/maxinterval/MaxIntervalTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/maxinterval/MaxIntervalTestCustomResource.java deleted file mode 100644 index 1c6cf81453..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/maxinterval/MaxIntervalTestCustomResource.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.javaoperatorsdk.operator.sample.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/sample/maxinterval/MaxIntervalTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/maxinterval/MaxIntervalTestCustomResourceStatus.java deleted file mode 100644 index 5c403febfb..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/maxinterval/MaxIntervalTestCustomResourceStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.javaoperatorsdk.operator.sample.maxinterval; - -public class MaxIntervalTestCustomResourceStatus { - -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/maxinterval/MaxIntervalTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/maxinterval/MaxIntervalTestReconciler.java deleted file mode 100644 index a5343c27a4..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/maxinterval/MaxIntervalTestReconciler.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.javaoperatorsdk.operator.sample.maxinterval; - -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import io.javaoperatorsdk.operator.api.reconciler.*; -import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; - -import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_FINALIZER; - -@ControllerConfiguration(finalizerName = NO_FINALIZER, - reconciliationMaxInterval = @ReconciliationMaxInterval(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/sample/multiversioncrd/MultiVersionCRDTestCustomResource1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResource1.java deleted file mode 100644 index 10236563c7..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResource1.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.javaoperatorsdk.operator.sample.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("mv1") -public class MultiVersionCRDTestCustomResource1 - extends - CustomResource - implements Namespaced { - - @Override - protected MultiVersionCRDTestCustomResourceStatus1 initStatus() { - return new MultiVersionCRDTestCustomResourceStatus1(); - } - -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResource2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResource2.java deleted file mode 100644 index de0a804049..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestCustomResource2.java +++ /dev/null @@ -1,24 +0,0 @@ -package io.javaoperatorsdk.operator.sample.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("mv2") -public class MultiVersionCRDTestCustomResource2 - extends - CustomResource - implements Namespaced { - - @Override - protected MultiVersionCRDTestCustomResourceStatus2 initStatus() { - return new MultiVersionCRDTestCustomResourceStatus2(); - } - -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestReconciler1.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestReconciler1.java deleted file mode 100644 index ab8ad417be..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestReconciler1.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.javaoperatorsdk.operator.sample.multiversioncrd; - -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 static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_FINALIZER; - -@ControllerConfiguration(finalizerName = NO_FINALIZER, 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()); - resource.getStatus().setValue1(resource.getStatus().getValue1() + 1); - if (!resource.getStatus().getReconciledBy().contains(getClass().getSimpleName())) { - resource.getStatus().getReconciledBy().add(getClass().getSimpleName()); - } - return UpdateControl.updateStatus(resource); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestReconciler2.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestReconciler2.java deleted file mode 100644 index d25297d1c6..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/multiversioncrd/MultiVersionCRDTestReconciler2.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.javaoperatorsdk.operator.sample.multiversioncrd; - -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 static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_FINALIZER; - -@ControllerConfiguration( - finalizerName = NO_FINALIZER, - 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()); - resource.getStatus().setValue1(resource.getStatus().getValue1() + 1); - if (!resource.getStatus().getReconciledBy().contains(getClass().getSimpleName())) { - resource.getStatus().getReconciledBy().add(getClass().getSimpleName()); - } - return UpdateControl.updateStatus(resource); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenerationTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenerationTestCustomResource.java deleted file mode 100644 index 51e4a1113a..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenerationTestCustomResource.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.javaoperatorsdk.operator.sample.observedgeneration; - -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("ObservedGenerationTestCustomResource") -@ShortNames("og") -public class ObservedGenerationTestCustomResource - extends CustomResource - implements Namespaced { - - @Override - protected ObservedGenerationTestCustomResourceStatus initStatus() { - return new ObservedGenerationTestCustomResourceStatus(); - } - -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenerationTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenerationTestCustomResourceStatus.java deleted file mode 100644 index 14071775f3..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenerationTestCustomResourceStatus.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.javaoperatorsdk.operator.sample.observedgeneration; - -import io.javaoperatorsdk.operator.api.ObservedGenerationAwareStatus; - -public class ObservedGenerationTestCustomResourceStatus extends ObservedGenerationAwareStatus { - -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenerationTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenerationTestReconciler.java deleted file mode 100644 index 65d7be4851..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/observedgeneration/ObservedGenerationTestReconciler.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.javaoperatorsdk.operator.sample.observedgeneration; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.javaoperatorsdk.operator.api.reconciler.*; - -import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_FINALIZER; - -@ControllerConfiguration(finalizerName = NO_FINALIZER) -public class ObservedGenerationTestReconciler - implements Reconciler { - - private static final Logger log = LoggerFactory.getLogger(ObservedGenerationTestReconciler.class); - - @Override - public UpdateControl reconcile( - ObservedGenerationTestCustomResource resource, Context context) { - log.info("Reconcile ObservedGenerationTestCustomResource: {}", - resource.getMetadata().getName()); - return UpdateControl.updateStatus(resource); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/retry/RetryTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/retry/RetryTestCustomReconciler.java deleted file mode 100644 index a76b5b0113..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/retry/RetryTestCustomReconciler.java +++ /dev/null @@ -1,66 +0,0 @@ -package io.javaoperatorsdk.operator.sample.retry; - -import java.util.concurrent.atomic.AtomicInteger; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -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 io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; - -@ControllerConfiguration -public class RetryTestCustomReconciler - implements Reconciler, TestExecutionInfoProvider { - - public static final String FINALIZER_NAME = - ReconcilerUtils.getDefaultFinalizerName(RetryTestCustomResource.class); - 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); - - if (!resource.getMetadata().getFinalizers().contains(FINALIZER_NAME)) { - throw new IllegalStateException("Finalizer is not present."); - } - 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.updateStatus(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/sample/simple/TestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestReconciler.java deleted file mode 100644 index 94a7d36868..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/simple/TestReconciler.java +++ /dev/null @@ -1,142 +0,0 @@ -package io.javaoperatorsdk.operator.sample.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.fabric8.kubernetes.client.KubernetesClient; -import io.javaoperatorsdk.operator.ReconcilerUtils; -import io.javaoperatorsdk.operator.api.reconciler.*; -import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.junit.KubernetesClientAware; -import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; - -@ControllerConfiguration(generationAwareEventProcessing = false) -public class TestReconciler - implements Reconciler, TestExecutionInfoProvider, - KubernetesClientAware { - - 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 KubernetesClient kubernetesClient; - private boolean updateStatus; - - public TestReconciler() { - this(true); - } - - public TestReconciler(boolean updateStatus) { - this.updateStatus = updateStatus; - } - - public boolean isUpdateStatus() { - return updateStatus; - } - - public void setUpdateStatus(boolean updateStatus) { - this.updateStatus = updateStatus; - } - - @Override - public KubernetesClient getKubernetesClient() { - return kubernetesClient; - } - - @Override - public void setKubernetesClient(KubernetesClient kubernetesClient) { - this.kubernetesClient = kubernetesClient; - } - - @Override - public DeleteControl cleanup( - TestCustomResource resource, Context context) { - Boolean delete = - kubernetesClient - .configMaps() - .inNamespace(resource.getMetadata().getNamespace()) - .withName(resource.getSpec().getConfigMapName()) - .delete(); - if (delete) { - 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."); - } - - 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", 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()) - .createOrReplace(newConfigMap); - } - if (updateStatus) { - if (resource.getStatus() == null) { - resource.setStatus(new TestCustomResourceStatus()); - } - resource.getStatus().setConfigMapStatus("ConfigMap Ready"); - } - return UpdateControl.updateStatus(resource); - } - - 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(); - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/subresource/SubResourceTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/subresource/SubResourceTestCustomReconciler.java deleted file mode 100644 index 081100beb3..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/subresource/SubResourceTestCustomReconciler.java +++ /dev/null @@ -1,51 +0,0 @@ -package io.javaoperatorsdk.operator.sample.subresource; - -import java.util.concurrent.atomic.AtomicInteger; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -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 io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; - -@ControllerConfiguration(generationAwareEventProcessing = false) -public class SubResourceTestCustomReconciler - implements Reconciler, TestExecutionInfoProvider { - - public static final String FINALIZER_NAME = - ReconcilerUtils.getDefaultFinalizerName(SubResourceTestCustomResource.class); - 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); - if (!resource.getMetadata().getFinalizers().contains(FINALIZER_NAME)) { - throw new IllegalStateException("Finalizer is not present."); - } - log.info("Value: " + resource.getSpec().getValue()); - - ensureStatusExists(resource); - resource.getStatus().setState(SubResourceTestCustomResourceStatus.State.SUCCESS); - - return UpdateControl.updateStatus(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/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/TestUtils.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/TestUtils.java index c2b5841d68..3d40690f09 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/TestUtils.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/support/TestUtils.java @@ -4,15 +4,14 @@ import java.util.UUID; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; -import io.javaoperatorsdk.operator.junit.OperatorExtension; -import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; -import io.javaoperatorsdk.operator.sample.simple.TestCustomResourceSpec; +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 final String TEST_NAMESPACE = "java-operator-sdk-int-test"; public static TestCustomResource testCustomResource() { return testCustomResource(UUID.randomUUID().toString()); @@ -38,9 +37,7 @@ public static TestCustomResource testCustomResource(String uid) { public static TestCustomResource testCustomResourceWithPrefix(String id) { TestCustomResource resource = new TestCustomResource(); resource.setMetadata( - new ObjectMetaBuilder() - .withName(TEST_CUSTOM_RESOURCE_PREFIX + id) - .build()); + new ObjectMetaBuilder().withName(TEST_CUSTOM_RESOURCE_PREFIX + id).build()); resource.setKind("CustomService"); resource.setSpec(new TestCustomResourceSpec()); resource.getSpec().setConfigMapName("test-config-map-" + id); @@ -57,7 +54,7 @@ public static void waitXms(int x) { } } - public static int getNumberOfExecutions(OperatorExtension extension) { + 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/compile-fixtures/MultilevelReconciler.java b/operator-framework/src/test/resources/compile-fixtures/MultilevelReconciler.java index acea0a0db2..254d211bd0 100644 --- a/operator-framework/src/test/resources/compile-fixtures/MultilevelReconciler.java +++ b/operator-framework/src/test/resources/compile-fixtures/MultilevelReconciler.java @@ -17,7 +17,7 @@ public static class MyCustomResource extends CustomResource { public UpdateControl reconcile( MultilevelReconciler.MyCustomResource customResource, Context context) { - return UpdateControl.updateResource(null); + return UpdateControl.patchResource(null); } public DeleteControl cleanup(MultilevelReconciler.MyCustomResource customResource, diff --git a/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplemented2Interfaces.java b/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplemented2Interfaces.java index 5ab0596319..bd1ba773be 100644 --- a/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplemented2Interfaces.java +++ b/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplemented2Interfaces.java @@ -1,22 +1,20 @@ 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.Reconciler; -import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.*; + import java.io.Serializable; @ControllerConfiguration -public class ReconcilerImplemented2Interfaces implements Serializable, Reconciler { +public class ReconcilerImplemented2Interfaces implements Serializable, + Reconciler, Cleaner { public static class MyCustomResource extends CustomResource { } @Override public UpdateControl reconcile(MyCustomResource customResource, Context context) { - return UpdateControl.updateResource(null); + return UpdateControl.patchResource(null); } @Override diff --git a/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplementedIntermediateAbstractClass.java b/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplementedIntermediateAbstractClass.java index b95495a614..ee291cf9ce 100644 --- a/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplementedIntermediateAbstractClass.java +++ b/operator-framework/src/test/resources/compile-fixtures/ReconcilerImplementedIntermediateAbstractClass.java @@ -13,7 +13,7 @@ public class ReconcilerImplementedIntermediateAbstractClass extends public UpdateControl reconcile( AbstractReconciler.MyCustomResource customResource, Context context) { - return UpdateControl.updateResource(null); + return UpdateControl.patchResource(null); } public DeleteControl cleanup(AbstractReconciler.MyCustomResource customResource, diff --git a/operator-framework/src/test/resources/configmap.yaml b/operator-framework/src/test/resources/configmap.yaml new file mode 100644 index 0000000000..3245e257ab --- /dev/null +++ b/operator-framework/src/test/resources/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: "" +data: + 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 f23cf772dd..82d8fa2cb1 100644 --- a/operator-framework/src/test/resources/log4j2.xml +++ b/operator-framework/src/test/resources/log4j2.xml @@ -1,12 +1,15 @@ - + - + + + + diff --git a/pom.xml b/pom.xml index 4dc1a81a7c..303c11f79b 100644 --- a/pom.xml +++ b/pom.xml @@ -1,444 +1,558 @@ - - 4.0.0 + + 4.0.0 - io.javaoperatorsdk - java-operator-sdk - 2.1.2-SNAPSHOT - Operator SDK for Java - Java SDK for implementing Kubernetes operators - pom - https://github.com/java-operator-sdk/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 - csviri@gmail.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/java-operator-sdk/java-operator-sdk.git - scm:git:git@github.com/java-operator-sdk/java-operator-sdk.git - https://github.com/java-operator-sdk/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 + - - UTF-8 - 11 - ${java.version} - ${java.version} - java-operator-sdk - https://sonarcloud.io + + 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 + - 5.8.2 - 5.12.0 - 1.7.36 - 2.17.1 - 4.3.1 - 3.12.0 - 1.0.1 - 0.19 - 1.13.0 - 3.22.0 - 4.1.1 - 2.6.3 - 1.8.2 + + 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.9.0 - 3.0.0-M5 - 3.3.1 - 3.2.0 - 3.2.1 - 3.2.2 - 3.1.0 - 3.0.1 - 1.6.8 - 2.8.2 - 2.5.2 - 5.0.0 - 2.17.1 - 1.0 - 1.6.2 - + 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 + - - operator-framework-core - operator-framework-junit5 - operator-framework - smoke-test-samples - micrometer-support - sample-operators - + + + + 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} + + + - - - - - io.fabric8 - kubernetes-client-bom - ${fabric8-client.version} - pom - import - - - org.junit - junit-bom - ${junit.version} - pom - import - - - - org.apache.commons - commons-lang3 - ${commons-lang3.version} - - - com.google.auto.service - auto-service - ${auto-service.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} - - - - org.assertj - assertj-core - ${assertj.version} - - - org.mockito - mockito-core - ${mokito.version} - - - - org.slf4j - slf4j-api - ${slf4j.version} - - - org.apache.logging.log4j - log4j-slf4j-impl - ${log4j.version} - - - org.apache.logging.log4j - log4j-core - ${log4j.version} - - - - io.javaoperatorsdk - operator-framework-core - ${project.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 + + + - io.javaoperatorsdk - operator-framework - ${project.version} + me.fabriciorby + maven-surefire-junit5-tree-reporter + 1.5.1 - - + + + + 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 + + + - - - ossrh - https://oss.sonatype.org/content/repositories/snapshots - - - - - - - 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} - - 3 - - - - org.apache.maven.plugins - maven-source-plugin - ${maven-source-plugin.version} - - - org.apache.maven.plugins - maven-gpg-plugin - ${maven-gpg-plugin.version} - - - org.apache.maven.plugins - maven-install-plugin - ${maven-install-plugin.version} - - - net.revelc.code.formatter - formatter-maven-plugin - ${formatter-maven-plugin.version} - - .cache - - - - net.revelc.code - impsort-maven-plugin - ${impsort-maven-plugin.version} - - .cache - java.,javax.,org.,io.,com. - * - true - true - - - - + + + + + integration-tests + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*IT.java + + + **/*Test.java + **/*E2E.java + + + + + + + + integration-tests-baseapi + - - org.commonjava.maven.plugins - directory-maven-plugin - ${directory-maven-plugin.version} - - - directories - - highest-basedir - - initialize - - josdk.project.root - - - - - - org.apache.maven.plugins - maven-surefire-plugin + + 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 + + + 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 - - **/*Test.java - - - **/*IT.java - **/*E2E.java - + + --pinentry-mode + loopback + - - - net.revelc.code.formatter - formatter-maven-plugin - - - - format - - - - ${josdk.project.root}/contributing/eclipse-google-style.xml - - - - - - net.revelc.code - impsort-maven-plugin - - - sort - - sort - - - - + + + + + org.sonatype.central + central-publishing-maven-plugin + ${central-publishing-maven-plugin.version} + true + + central + true + true + published + + - - - - - all-tests - - - - org.apache.maven.plugins - maven-surefire-plugin - - - **/*Test.java - **/*IT.java - **/*E2E.java - - - - - - - - no-unit-tests - - - - org.apache.maven.plugins - maven-surefire-plugin - - - **/*IT.java - - - **/*Test.java - **/*E2E.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 - - - - - org.apache.maven.plugins - maven-javadoc-plugin - ${maven-javadoc-plugin.version} - - - attach-javadocs - - jar - - - - - - org.apache.maven.plugins - maven-source-plugin - - - attach-sources - - jar - - - - - - org.apache.maven.plugins - maven-gpg-plugin - - - sign-artifacts - verify - - sign - - - - --pinentry-mode - loopback - - - - - - - org.sonatype.plugins - nexus-staging-maven-plugin - ${nexus-staging-maven-plugin.version} - 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/sample-operators/controller-namespace-deletion/src/test/resources/log4j2.xml b/sample-operators/controller-namespace-deletion/src/test/resources/log4j2.xml new file mode 100644 index 0000000000..2b7fdd3479 --- /dev/null +++ b/sample-operators/controller-namespace-deletion/src/test/resources/log4j2.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file 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 index 366dcf3e42..a0f7999090 100644 --- a/sample-operators/mysql-schema/README.md +++ b/sample-operators/mysql-schema/README.md @@ -23,9 +23,9 @@ 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 and 15) +* 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) +* 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 @@ -59,18 +59,45 @@ you want to use, you can skip this step, but you will have to configure the oper `kubectl apply -f k8s/mysql-db.yaml` 1. Deploy the CRD: - `kubectl apply -f k8s/crd.yaml` + `kubectl apply -f target/classes/META-INF/fabric8/mysqlschemas.mysql.sample.javaoperatorsdk-v1.yml` -1. Make a copy of `k8s/operator.yaml` and replace ${DOCKER_REGISTRY} and ${OPERATOR_VERSION} to the -right values. You will want to set `OPERATOR_VERSION` to the one used for building the Docker image. `DOCKER_REGISTRY` should -be the same as you set the docker-registry property in your `pom.xml`. +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 mysql-schema-operator -w`. `-w` will cause kubectl to continuously monitor -the deployment until you stop it. +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. +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/sample-operators/mysql-schema/k8s/mysql-db.yaml b/sample-operators/mysql-schema/k8s/mysql-db.yaml new file mode 100644 index 0000000000..d80238b32e --- /dev/null +++ b/sample-operators/mysql-schema/k8s/mysql-db.yaml @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mysql + labels: + name: mysql +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mysql + namespace: mysql +spec: + selector: + matchLabels: + app: mysql + strategy: + type: Recreate + template: + metadata: + labels: + app: mysql + spec: + containers: + - 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/mysql-deployment.yaml b/sample-operators/mysql-schema/k8s/mysql-deployment.yaml deleted file mode 100644 index a257b1b99d..0000000000 --- a/sample-operators/mysql-schema/k8s/mysql-deployment.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: mysql - namespace: mysql -spec: - selector: - matchLabels: - app: mysql - strategy: - type: Recreate - template: - metadata: - labels: - 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 diff --git a/sample-operators/mysql-schema/k8s/mysql-service.yaml b/sample-operators/mysql-schema/k8s/mysql-service.yaml deleted file mode 100644 index 4c67148be3..0000000000 --- a/sample-operators/mysql-schema/k8s/mysql-service.yaml +++ /dev/null @@ -1,11 +0,0 @@ -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 index f4b3296e09..48ddea6a35 100644 --- a/sample-operators/mysql-schema/k8s/operator.yaml +++ b/sample-operators/mysql-schema/k8s/operator.yaml @@ -23,7 +23,7 @@ spec: serviceAccountName: mysql-schema-operator # specify the ServiceAccount under which's RBAC persmissions the operator will be executed under containers: - name: operator - image: mysql-schema-operator + image: mysql-schema-operator # TODO Change this to point to your pushed mysql-schema-operator image imagePullPolicy: IfNotPresent ports: - containerPort: 80 diff --git a/sample-operators/mysql-schema/pom.xml b/sample-operators/mysql-schema/pom.xml index bd73c6390f..8201c1148e 100644 --- a/sample-operators/mysql-schema/pom.xml +++ b/sample-operators/mysql-schema/pom.xml @@ -1,113 +1,115 @@ - - 4.0.0 + + 4.0.0 - - io.javaoperatorsdk - sample-operators - 2.1.2-SNAPSHOT - - - sample-mysql-schema-operator - Operator SDK - Samples - MySQL Schema - Provisions Schemas in a MySQL database - jar + + io.javaoperatorsdk + sample-operators + 5.1.5-SNAPSHOT + - - 11 - 11 - 3.2.0 - + sample-mysql-schema-operator + jar + Operator SDK - Samples - MySQL Schema + Provisions Schemas in a MySQL database + - - io.javaoperatorsdk - operator-framework - ${project.version} - - - io.javaoperatorsdk - micrometer-support - ${project.version} - - - org.takes - takes - 1.19 - - - mysql - mysql-connector-java - 8.0.28 - - - io.fabric8 - crd-generator-apt - provided - - - org.apache.logging.log4j - log4j-slf4j-impl - - - org.junit.jupiter - junit-jupiter-api - test - - - org.junit.jupiter - junit-jupiter-engine - test - - - org.awaitility - awaitility - 4.1.0 - test - - - com.fasterxml.jackson.dataformat - jackson-dataformat-yaml - 2.13.1 - - - io.javaoperatorsdk - operator-framework-junit-5 - ${project.version} - test - + + 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 + + - - - - org.apache.maven.plugins - maven-surefire-plugin - - 0 - - - - com.google.cloud.tools - jib-maven-plugin - ${jib-maven-plugin.version} - - - gcr.io/distroless/java:11 - - - mysql-schema-operator - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.9.0 - - - + + + + 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 + + + + + - \ No newline at end of file + 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 index 7cc06dd373..6f409720fe 100644 --- 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 @@ -17,11 +17,14 @@ public MySQLDbConfig(String host, String port, String user, String password) { } public static MySQLDbConfig loadFromEnvironmentVars() { - if (ObjectUtils.anyNull(System.getenv("MYSQL_HOST"), - System.getenv("MYSQL_USER"), System.getenv("MYSQL_PASSWORD"))) { + 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"), + return new MySQLDbConfig( + System.getenv("MYSQL_HOST"), System.getenv("MYSQL_PORT"), System.getenv("MYSQL_USER"), System.getenv("MYSQL_PASSWORD")); @@ -43,4 +46,3 @@ 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 index 80eb25f8c7..adc6335c43 100644 --- 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 @@ -7,5 +7,4 @@ @Group("mysql.sample.javaoperatorsdk") @Version("v1") -public class MySQLSchema extends CustomResource implements Namespaced { -} +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 index ebccbd4fe5..c155f0ac6b 100644 --- a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java @@ -1,6 +1,7 @@ package io.javaoperatorsdk.operator.sample; import java.io.IOException; +import java.time.Duration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -9,14 +10,10 @@ import org.takes.http.Exit; import org.takes.http.FtBasic; -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 io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; -import io.javaoperatorsdk.operator.config.runtime.DefaultConfigurationService; 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 { @@ -26,17 +23,24 @@ public class MySQLSchemaOperator { public static void main(String[] args) throws IOException { log.info("MySQL Schema Operator starting"); - Config config = new ConfigBuilder().withNamespace(null).build(); - KubernetesClient client = new DefaultKubernetesClient(config); - Operator operator = new Operator(client, - new ConfigurationServiceOverrider(DefaultConfigurationService.instance()) - .withMetrics(new MicrometerMetrics(new LoggingMeterRegistry())) - .build()); - operator.register(new MySQLSchemaReconciler(client, MySQLDbConfig.loadFromEnvironmentVars())); - operator.installShutdownHook(); + 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 index 18a2c7e518..38e94f4d8f 100644 --- 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 @@ -1,162 +1,81 @@ package io.javaoperatorsdk.operator.sample; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.SQLException; -import java.util.Base64; -import java.util.List; -import java.util.Optional; - -import org.apache.commons.lang3.RandomStringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import io.fabric8.kubernetes.api.model.OwnerReference; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.fabric8.kubernetes.api.model.Secret; -import io.fabric8.kubernetes.api.model.SecretBuilder; -import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.api.reconciler.*; -import io.javaoperatorsdk.operator.processing.event.source.EventSource; -import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingEventSource; +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 io.javaoperatorsdk.operator.sample.schema.SchemaService; +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; -@ControllerConfiguration(finalizerName = Constants.NO_FINALIZER) -public class MySQLSchemaReconciler - implements Reconciler, ErrorStatusHandler, - EventSourceInitializer { - public static final String SECRET_FORMAT = "%s-secret"; - public static final String USERNAME_FORMAT = "%s-user"; - public static final int POLL_PERIOD = 500; - private final Logger log = LoggerFactory.getLogger(getClass()); - - private final KubernetesClient kubernetesClient; - private final MySQLDbConfig mysqlDbConfig; - - public MySQLSchemaReconciler(KubernetesClient kubernetesClient, MySQLDbConfig mysqlDbConfig) { - this.kubernetesClient = kubernetesClient; - this.mysqlDbConfig = mysqlDbConfig; - } - - @Override - public List prepareEventSources( - EventSourceContext context) { - return List.of(new PerResourcePollingEventSource<>( - new SchemaPollingResourceSupplier(mysqlDbConfig), context.getPrimaryCache(), POLL_PERIOD, - Schema.class)); - } +@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 { - @Override - public UpdateControl reconcile(MySQLSchema schema, Context context) { - log.info("Reconciling MySQLSchema with name: {}", schema.getMetadata().getName()); - var dbSchema = context.getSecondaryResource(Schema.class); - log.debug("Schema: {} found for: {} ", dbSchema, schema.getMetadata().getName()); - try (Connection connection = getConnection()) { - if (dbSchema.isEmpty()) { - log.debug("Creating Schema and related resources for: {}", schema.getMetadata().getName()); - var schemaName = schema.getMetadata().getName(); - String password = RandomStringUtils.randomAlphanumeric(16); - String secretName = String.format(SECRET_FORMAT, schemaName); - String userName = String.format(USERNAME_FORMAT, schemaName); - - SchemaService.createSchemaAndRelatedUser(connection, schemaName, - schema.getSpec().getEncoding(), userName, password); - createSecret(schema, password, secretName, userName); - updateStatusPojo(schema, secretName, userName); - log.info("Schema {} created - updating CR status", schema.getMetadata().getName()); - return UpdateControl.updateStatus(schema); - } else { - log.debug("No update on MySQLSchema with name: {}", schema.getMetadata().getName()); - return UpdateControl.noUpdate(); - } - } catch (SQLException e) { - log.error("Error while creating Schema", e); - throw new IllegalStateException(e); - } - } + static final Logger log = LoggerFactory.getLogger(MySQLSchemaReconciler.class); @Override - public DeleteControl cleanup(MySQLSchema schema, Context context) { - log.info("Cleaning up for: {}", schema.getMetadata().getName()); - try (Connection connection = getConnection()) { - var dbSchema = SchemaService.getSchema(connection, schema.getMetadata().getName()); - if (dbSchema.isPresent()) { - var userName = schema.getStatus() != null ? schema.getStatus().getUserName() : null; - SchemaService.deleteSchemaAndRelatedUser(connection, schema.getMetadata().getName(), - userName); - } else { - log.info( - "Delete event ignored for schema '{}', real schema doesn't exist", - schema.getMetadata().getName()); - } - return DeleteControl.defaultDelete(); - } catch (SQLException e) { - log.error("Error while trying to delete Schema", e); - return DeleteControl.noFinalizerRemoval(); - } + 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 Optional updateErrorStatus(MySQLSchema schema, RetryInfo retryInfo, - RuntimeException e) { + 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 Optional.empty(); + return ErrorStatusUpdateControl.patchStatus(schema); } - private Connection getConnection() throws SQLException { - 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()); - } - - private void updateStatusPojo(MySQLSchema schema, String secretName, String userName) { + 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.getMetadata().getName())); + status.setUrl(format("jdbc:mysql://%1$s/%2$s", System.getenv("MYSQL_HOST"), schema.getName())); status.setUserName(userName); status.setSecretName(secretName); status.setStatus("CREATED"); - schema.setStatus(status); + res.setStatus(status); + return res; } - - private void createSecret(MySQLSchema schema, String password, String secretName, - String userName) { - - var currentSecret = kubernetesClient.secrets().inNamespace(schema.getMetadata().getNamespace()) - .withName(secretName).get(); - if (currentSecret != null) { - return; - } - Secret credentialsSecret = - new SecretBuilder() - .withNewMetadata() - .withName(secretName) - .withOwnerReferences(new OwnerReference("mysql.sample.javaoperatorsdk/v1", - false, false, "MySQLSchema", - schema.getMetadata().getName(), schema.getMetadata().getUid())) - .endMetadata() - .addToData( - "MYSQL_USERNAME", Base64.getEncoder().encodeToString(userName.getBytes())) - .addToData( - "MYSQL_PASSWORD", Base64.getEncoder().encodeToString(password.getBytes())) - .build(); - this.kubernetesClient - .secrets() - .inNamespace(schema.getMetadata().getNamespace()) - .create(credentialsSecret); - } - - } diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/SchemaPollingResourceSupplier.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/SchemaPollingResourceSupplier.java deleted file mode 100644 index e7be1b3c42..0000000000 --- a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/SchemaPollingResourceSupplier.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.javaoperatorsdk.operator.sample; - -import java.util.Optional; - -import io.javaoperatorsdk.operator.processing.event.source.polling.PerResourcePollingEventSource; -import io.javaoperatorsdk.operator.sample.schema.Schema; -import io.javaoperatorsdk.operator.sample.schema.SchemaService; - -public class SchemaPollingResourceSupplier - implements PerResourcePollingEventSource.ResourceSupplier { - - private final SchemaService schemaService; - - public SchemaPollingResourceSupplier(MySQLDbConfig mySQLDbConfig) { - this.schemaService = new SchemaService(mySQLDbConfig); - } - - @Override - public Optional getResource(MySQLSchema resource) { - return schemaService.getSchema(resource.getMetadata().getName()); - } -} 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 index 92ddb67a63..168cd8db15 100644 --- 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 @@ -1,8 +1,6 @@ package io.javaoperatorsdk.operator.sample; -import io.javaoperatorsdk.operator.api.ObservedGenerationAwareStatus; - -public class SchemaStatus extends ObservedGenerationAwareStatus { +public class SchemaStatus { private String url; 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 index 836951a004..3ec6d8f008 100644 --- 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 @@ -5,8 +5,8 @@ public class Schema implements Serializable { - private String name; - private String characterSet; + private final String name; + private final String characterSet; public Schema(String name, String characterSet) { this.name = name; @@ -23,16 +23,19 @@ public String getCharacterSet() { @Override public boolean equals(Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; Schema schema = (Schema) o; - return Objects.equals(name, schema.name) && Objects.equals(characterSet, schema.characterSet); + return Objects.equals(name, schema.name); } @Override public int hashCode() { - return Objects.hash(name, characterSet); + 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 index 2944a88fd8..3690219902 100644 --- 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 @@ -12,7 +12,6 @@ public class SchemaService { - private static final Logger log = LoggerFactory.getLogger(SchemaService.class); private final MySQLDbConfig mySQLDbConfig; @@ -29,16 +28,12 @@ public Optional getSchema(String name) { } } - public static void createSchemaAndRelatedUser(Connection connection, String schemaName, - String encoding, - String userName, - String password) { + 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)); + format("CREATE SCHEMA `%1$s` DEFAULT CHARACTER SET %2$s", schemaName, encoding)); } if (!userExists(connection, userName)) { try (Statement statement = connection.createStatement()) { @@ -46,22 +41,26 @@ public static void createSchemaAndRelatedUser(Connection connection, String sche } } try (Statement statement = connection.createStatement()) { - statement.execute( - format("GRANT ALL ON `%1$s`.* TO '%2$s'", schemaName, userName)); + 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) { + public static void deleteSchemaAndRelatedUser( + Connection connection, String schemaName, String userName) { try { - try (Statement statement = connection.createStatement()) { - statement.execute(format("DROP DATABASE `%1$s`", schemaName)); + if (schemaExists(connection, schemaName)) { + try (Statement statement = connection.createStatement()) { + statement.execute(format("DROP DATABASE `%1$s`", schemaName)); + } + log.info("Deleted Schema '{}'", schemaName); } - log.info("Deleted Schema '{}'", schemaName); - if (userName != null) { + + if (userName != null && userExists(connection, userName)) { try (Statement statement = connection.createStatement()) { statement.execute(format("DROP USER '%1$s'", userName)); } @@ -75,8 +74,7 @@ public static void deleteSchemaAndRelatedUser(Connection connection, String sche private static boolean userExists(Connection connection, String username) { try (PreparedStatement ps = - connection.prepareStatement( - "SELECT 1 FROM mysql.user WHERE user = ?")) { + connection.prepareStatement("SELECT 1 FROM mysql.user WHERE user = ?")) { ps.setString(1, username); try (ResultSet resultSet = ps.executeQuery()) { return resultSet.next(); @@ -86,6 +84,10 @@ private static boolean userExists(Connection connection, String username) { } } + 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( @@ -97,8 +99,10 @@ public static Optional getSchema(Connection connection, String schemaNam if (!exists) { return Optional.empty(); } else { - return Optional.of(new Schema(resultSet.getString("SCHEMA_NAME"), - resultSet.getString("DEFAULT_CHARACTER_SET_NAME"))); + return Optional.of( + new Schema( + resultSet.getString("SCHEMA_NAME"), + resultSet.getString("DEFAULT_CHARACTER_SET_NAME"))); } } } catch (SQLException e) { @@ -112,11 +116,10 @@ private Connection getConnection() { 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()); + return DriverManager.getConnection( + connectionString, mySQLDbConfig.getUser(), mySQLDbConfig.getPassword()); } catch (SQLException e) { throw new IllegalStateException(e); } } - } diff --git a/sample-operators/mysql-schema/src/main/resources/log4j2.xml b/sample-operators/mysql-schema/src/main/resources/log4j2.xml index 5ab4735126..01484221f9 100644 --- a/sample-operators/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 index cf8284f179..92339d0e2c 100644 --- a/sample-operators/mysql-schema/src/test/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperatorE2E.java +++ b/sample-operators/mysql-schema/src/test/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperatorE2E.java @@ -2,7 +2,6 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; -import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -14,13 +13,12 @@ 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.DefaultKubernetesClient; import io.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.LocalPortForward; -import io.javaoperatorsdk.operator.config.runtime.DefaultConfigurationService; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; import io.javaoperatorsdk.operator.junit.AbstractOperatorExtension; -import io.javaoperatorsdk.operator.junit.E2EOperatorExtension; -import io.javaoperatorsdk.operator.junit.OperatorExtension; +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; @@ -28,28 +26,24 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; -public class MySQLSchemaOperatorE2E { +class MySQLSchemaOperatorE2E { - final static Logger log = LoggerFactory.getLogger(MySQLSchemaOperatorE2E.class); + static final Logger log = LoggerFactory.getLogger(MySQLSchemaOperatorE2E.class); - final static KubernetesClient client = new DefaultKubernetesClient(); + static final KubernetesClient client = new KubernetesClientBuilder().build(); - final static String MY_SQL_NS = "mysql"; + static final String MY_SQL_NS = "mysql"; + + private static final List infrastructure = new ArrayList<>(); + public static final String TEST_RESOURCE_NAME = "mydb1"; - private static List infrastructure = new ArrayList<>(); static { - infrastructure - .add(new NamespaceBuilder() - .withNewMetadata() - .withName(MY_SQL_NS) - .endMetadata() - .build()); + infrastructure.add( + new NamespaceBuilder().withNewMetadata().withName(MY_SQL_NS).endMetadata().build()); try { - infrastructure.addAll( - client.load(new FileInputStream("k8s/mysql-deployment.yaml")).get()); - infrastructure.addAll( - client.load(new FileInputStream("k8s/mysql-service.yaml")).get()); + infrastructure.addAll(client.load(new FileInputStream("k8s/mysql-db.yaml")).items()); } catch (FileNotFoundException e) { e.printStackTrace(); } @@ -58,75 +52,77 @@ public class MySQLSchemaOperatorE2E { boolean isLocal() { String deployment = System.getProperty("test.deployment"); boolean remote = (deployment != null && deployment.equals("remote")); - log.info("Running the operator " + (remote ? "remote" : "locally")); + log.info("Running the operator " + (remote ? "remotely" : "locally")); return !remote; } @RegisterExtension - AbstractOperatorExtension operator = isLocal() ? OperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(new MySQLSchemaReconciler(client, - new MySQLDbConfig("127.0.0.1", "3306", "root", "password"))) - .withInfrastructure(infrastructure) - .build() - : E2EOperatorExtension.builder() - .withConfigurationService(DefaultConfigurationService.instance()) - .withOperatorDeployment( - client.load(new FileInputStream("k8s/operator.yaml")).get()) - .withInfrastructure(infrastructure) - .build(); - - + 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 - public void test() throws IOException { - // Opening a port-forward if running locally - LocalPortForward portForward = null; - if (isLocal()) { - String podName = client - .pods() - .inNamespace(MY_SQL_NS) - .withLabel("app", "mysql") - .list() - .getItems() - .get(0) - .getMetadata() - .getName(); - - portForward = client - .pods() - .inNamespace(MY_SQL_NS) - .withName(podName) - .portForward(3306, 3306); - } + void test() { MySQLSchema testSchema = new MySQLSchema(); - testSchema.setMetadata(new ObjectMetaBuilder() - .withName("mydb1") - .withNamespace(operator.getNamespace()) - .build()); + 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 5 minutes for expected resources to be created and updated"); - await().atMost(1, 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())); - }); - - if (portForward != null) { - portForward.close(); - } + 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 index 933395a4fb..c19bb7f3f6 100644 --- a/sample-operators/pom.xml +++ b/sample-operators/pom.xml @@ -1,26 +1,22 @@ - - 4.0.0 + + 4.0.0 - - io.javaoperatorsdk - java-operator-sdk - 2.1.2-SNAPSHOT - + + io.javaoperatorsdk + java-operator-sdk + 5.1.5-SNAPSHOT + - sample-operators - Operator SDK - Samples - pom + sample-operators + pom + Operator SDK - Samples - - 3.1.4 - - - - tomcat-operator - webpage - mysql-schema - - \ No newline at end of file + + 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 index 95168c2196..82d633d237 100644 --- a/sample-operators/tomcat-operator/README.md +++ b/sample-operators/tomcat-operator/README.md @@ -1,6 +1,13 @@ # Tomcat Operator -Creates a Tomcat deployment from a Custom Resource, while keeping the WAR separated with another Custom Resource. +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 diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index 21c3fbc405..0c43071f16 100644 --- a/sample-operators/tomcat-operator/pom.xml +++ b/sample-operators/tomcat-operator/pom.xml @@ -1,98 +1,121 @@ - - 4.0.0 + + 4.0.0 - - io.javaoperatorsdk - sample-operators - 2.1.2-SNAPSHOT - - - sample-tomcat-operator - Operator SDK - Samples - Tomcat - Provisions Tomcat Pods and deploys Webapplications in them - jar + + io.javaoperatorsdk + sample-operators + 5.1.5-SNAPSHOT + - - 11 - 11 - 3.2.0 - + sample-tomcat-operator + jar + Operator SDK - Samples - Tomcat + Provisions Tomcat Pods and deploys Webapplications in them + - - io.javaoperatorsdk - operator-framework - ${project.version} - - - io.fabric8 - crd-generator-apt - provided - - - org.apache.logging.log4j - log4j-slf4j-impl - - - org.takes - takes - 1.19 - - - org.junit.jupiter - junit-jupiter-api - test - - - org.junit.jupiter - junit-jupiter-engine - test - - - org.awaitility - awaitility - 4.1.1 - test - - - io.javaoperatorsdk - operator-framework-junit-5 - ${project.version} - test - + + 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/java:11 - - - tomcat-operator - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.9.0 - - - + + + + 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 + + + + + + - \ No newline at end of file + 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/TomcatOperator.java b/sample-operators/tomcat-operator/src/main/java/io/javaoperatorsdk/operator/sample/TomcatOperator.java index 487183dfe5..370a488bc9 100644 --- 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 @@ -9,12 +9,7 @@ import org.takes.http.Exit; import org.takes.http.FtBasic; -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 io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.config.runtime.DefaultConfigurationService; public class TomcatOperator { @@ -22,12 +17,9 @@ public class TomcatOperator { public static void main(String[] args) throws IOException { - Config config = new ConfigBuilder().withNamespace(null).build(); - KubernetesClient client = new DefaultKubernetesClient(config); - Operator operator = new Operator(client, DefaultConfigurationService.instance()); - operator.register(new TomcatReconciler(client)); - operator.register(new WebappReconciler(client)); - operator.installShutdownHook(); + 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 index bb67128f9e..5cef5ea25c 100644 --- 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 @@ -1,162 +1,60 @@ package io.javaoperatorsdk.operator.sample; -import java.io.IOException; -import java.io.InputStream; -import java.util.List; import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import io.fabric8.kubernetes.api.model.OwnerReference; -import io.fabric8.kubernetes.api.model.Service; +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.fabric8.kubernetes.client.KubernetesClient; -import io.fabric8.kubernetes.client.informers.SharedIndexInformer; -import io.fabric8.kubernetes.client.utils.Serialization; -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.EventSourceInitializer; -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; - -import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_FINALIZER; +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. */ -@ControllerConfiguration(finalizerName = NO_FINALIZER) -public class TomcatReconciler implements Reconciler, EventSourceInitializer { +@Workflow( + dependents = { + @Dependent(type = DeploymentDependentResource.class), + @Dependent(type = ServiceDependentResource.class) + }) +@ControllerConfiguration +public class TomcatReconciler implements Reconciler { private final Logger log = LoggerFactory.getLogger(getClass()); - private final KubernetesClient kubernetesClient; - - public TomcatReconciler(KubernetesClient client) { - this.kubernetesClient = client; - } - - @Override - public List prepareEventSources(EventSourceContext context) { - SharedIndexInformer deploymentInformer = - kubernetesClient.apps().deployments().inAnyNamespace() - .withLabel("app.kubernetes.io/managed-by", "tomcat-operator") - .runnableInformer(0); - - return List.of(new InformerEventSource<>( - deploymentInformer, Mappers.fromOwnerReference())); - } - @Override - public UpdateControl reconcile(Tomcat tomcat, Context context) { - createOrUpdateDeployment(tomcat); - createOrUpdateService(tomcat); - - return context.getSecondaryResource(Deployment.class) - .map(deployment -> { - Tomcat updatedTomcat = - updateTomcatStatus(tomcat, deployment); - log.info( - "Updating status of Tomcat {} in namespace {} to {} ready replicas", - tomcat.getMetadata().getName(), - tomcat.getMetadata().getNamespace(), - tomcat.getStatus().getReadyReplicas()); - return UpdateControl.updateStatus(updatedTomcat); - }) - .orElse(UpdateControl.noUpdate()); + 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 updateTomcatStatus(Tomcat tomcat, Deployment deployment) { + 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); - tomcat.setStatus(status); - return tomcat; - } - - private void createOrUpdateDeployment(Tomcat tomcat) { - String ns = tomcat.getMetadata().getNamespace(); - Deployment existingDeployment = - kubernetesClient - .apps() - .deployments() - .inNamespace(ns) - .withName(tomcat.getMetadata().getName()) - .get(); - if (existingDeployment == null) { - Deployment deployment = loadYaml(Deployment.class, "deployment.yaml"); - deployment.getMetadata().setName(tomcat.getMetadata().getName()); - deployment.getMetadata().setNamespace(ns); - deployment.getMetadata().getLabels().put("app.kubernetes.io/part-of", - tomcat.getMetadata().getName()); - deployment.getMetadata().getLabels().put("app.kubernetes.io/managed-by", "tomcat-operator"); - // set tomcat version - deployment - .getSpec() - .getTemplate() - .getSpec() - .getContainers() - .get(0) - .setImage("tomcat:" + tomcat.getSpec().getVersion()); - deployment.getSpec().setReplicas(tomcat.getSpec().getReplicas()); - - // make sure label selector matches label (which has to be matched by service selector too) - deployment - .getSpec() - .getTemplate() - .getMetadata() - .getLabels() - .put("app", tomcat.getMetadata().getName()); - deployment - .getSpec() - .getSelector() - .getMatchLabels() - .put("app", tomcat.getMetadata().getName()); - - OwnerReference ownerReference = deployment.getMetadata().getOwnerReferences().get(0); - ownerReference.setName(tomcat.getMetadata().getName()); - ownerReference.setUid(tomcat.getMetadata().getUid()); - - log.info("Creating or updating Deployment {} in {}", deployment.getMetadata().getName(), ns); - kubernetesClient.apps().deployments().inNamespace(ns).create(deployment); - } else { - existingDeployment - .getSpec() - .getTemplate() - .getSpec() - .getContainers() - .get(0) - .setImage("tomcat:" + tomcat.getSpec().getVersion()); - existingDeployment.getSpec().setReplicas(tomcat.getSpec().getReplicas()); - kubernetesClient.apps().deployments().inNamespace(ns).createOrReplace(existingDeployment); - } - } - - private void createOrUpdateService(Tomcat tomcat) { - Service service = loadYaml(Service.class, "service.yaml"); - service.getMetadata().setName(tomcat.getMetadata().getName()); - String ns = tomcat.getMetadata().getNamespace(); - service.getMetadata().setNamespace(ns); - service.getMetadata().getOwnerReferences().get(0).setName(tomcat.getMetadata().getName()); - service.getMetadata().getOwnerReferences().get(0).setUid(tomcat.getMetadata().getUid()); - service.getSpec().getSelector().put("app", tomcat.getMetadata().getName()); - log.info("Creating or updating Service {} in {}", service.getMetadata().getName(), ns); - kubernetesClient.services().inNamespace(ns).createOrReplace(service); - } - - 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); - } + res.setStatus(status); + return res; } } 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 index d61f8791f7..2d5ce3f925 100644 --- 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 @@ -8,9 +8,7 @@ import io.fabric8.kubernetes.model.annotation.Group; import io.fabric8.kubernetes.model.annotation.Version; -/** - * Represents a web application deployed in a Tomcat deployment - */ +/** Represents a web application deployed in a Tomcat deployment */ @Group("tomcatoperator.io") @Version("v1") public class Webapp extends CustomResource implements Namespaced { 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 index 719c435b08..0a26aece2e 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -12,51 +13,62 @@ 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.EventSourceInitializer; 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; -import static io.javaoperatorsdk.operator.api.reconciler.Constants.NO_FINALIZER; +@ControllerConfiguration +public class WebappReconciler implements Reconciler, Cleaner { -@ControllerConfiguration(finalizerName = NO_FINALIZER) -public class WebappReconciler implements Reconciler, EventSourceInitializer { + private static final Logger log = LoggerFactory.getLogger(WebappReconciler.class); - private KubernetesClient kubernetesClient; - - private final Logger log = LoggerFactory.getLogger(getClass()); + private final KubernetesClient kubernetesClient; public WebappReconciler(KubernetesClient kubernetesClient) { this.kubernetesClient = kubernetesClient; } @Override - public List prepareEventSources(EventSourceContext context) { - return List.of(new InformerEventSource<>( - kubernetesClient, Tomcat.class, t -> { - // 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 to - // and identify it based on naming convention. - return context.getPrimaryCache() - .list(webApp -> webApp.getSpec().getTomcat().equals(t.getMetadata().getName())) - .map(ResourceID::fromResource) - .collect(Collectors.toSet()); - }, - (Webapp webapp) -> new ResourceID(webapp.getSpec().getTomcat(), - webapp.getMetadata().getNamespace()), - true)); + 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)); } /** @@ -64,48 +76,76 @@ public List prepareEventSources(EventSourceContext context) * change. */ @Override - public UpdateControl reconcile(Webapp webapp, Context context) { + 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())); + 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()}; + 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()}; + command = + new String[] { + "time", + "wget", + "-O", + "/data/" + webapp.getSpec().getContextPath() + ".war", + webapp.getSpec().getUrl() + }; } String[] commandStatusInAllPods = executeCommandInAllPods(kubernetesClient, webapp, command); - if (webapp.getStatus() == null) { - webapp.setStatus(new WebappStatus()); - } - webapp.getStatus().setDeployedArtifact(webapp.getSpec().getUrl()); - webapp.getStatus().setDeploymentStatus(commandStatusInAllPods); - return UpdateControl.updateStatus(webapp); + return UpdateControl.patchStatus(createWebAppForStatusUpdate(webapp, commandStatusInAllPods)); } else { - log.info("WebappController invoked but Tomcat not ready yet ({}/{})", + 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) { + public DeleteControl cleanup(Webapp webapp, Context context) { String[] command = new String[] {"rm", "/data/" + webapp.getSpec().getContextPath() + ".war"}; String[] commandStatusInAllPods = executeCommandInAllPods(kubernetesClient, webapp, command); @@ -146,14 +186,13 @@ private String[] executeCommandInAllPods( CompletableFuture data = new CompletableFuture<>(); try (ExecWatch execWatch = execCmd(pod, data, command)) { - status[i] = "" + pod.getMetadata().getName() + ":" + data.get(30, TimeUnit.SECONDS);; + status[i] = pod.getMetadata().getName() + ":" + data.get(30, TimeUnit.SECONDS); } catch (ExecutionException e) { - status[i] = "" + pod.getMetadata().getName() + ": ExecutionException - " + e.getMessage(); + status[i] = pod.getMetadata().getName() + ": ExecutionException - " + e.getMessage(); } catch (InterruptedException e) { - status[i] = - "" + pod.getMetadata().getName() + ": InterruptedException - " + e.getMessage(); + status[i] = pod.getMetadata().getName() + ": InterruptedException - " + e.getMessage(); } catch (TimeoutException e) { - status[i] = "" + pod.getMetadata().getName() + ": TimeoutException - " + e.getMessage(); + status[i] = pod.getMetadata().getName() + ": TimeoutException - " + e.getMessage(); } } } @@ -162,7 +201,8 @@ private String[] executeCommandInAllPods( private ExecWatch execCmd(Pod pod, CompletableFuture data, String... command) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); - return kubernetesClient.pods() + return kubernetesClient + .pods() .inNamespace(pod.getMetadata().getNamespace()) .withName(pod.getMetadata().getName()) .inContainer("war-downloader") @@ -174,8 +214,8 @@ private ExecWatch execCmd(Pod pod, CompletableFuture data, String... com static class SimpleListener implements ExecListener { - private CompletableFuture data; - private ByteArrayOutputStream baos; + private final CompletableFuture data; + private final ByteArrayOutputStream baos; private final Logger log = LoggerFactory.getLogger(getClass()); public SimpleListener(CompletableFuture data, ByteArrayOutputStream baos) { @@ -196,9 +236,8 @@ public void onFailure(Throwable t, Response response) { @Override public void onClose(int code, String reason) { - log.debug("Exit with: " + code + " and with reason: " + reason); + log.debug("Exit with: {} and with reason: {}", code, reason); data.complete(baos.toString()); } } - } 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 index 55d1a6be7d..aa38eb3619 100644 --- 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 @@ -5,11 +5,6 @@ metadata: labels: app.kubernetes.io/part-of: "" app.kubernetes.io/managed-by: "" # used for filtering of Deployments created by the controller - ownerReferences: # used for finding which Tomcat does this Deployment belong to - - apiVersion: apps/v1 - kind: Tomcat - name: "" - uid: "" spec: selector: matchLabels: 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 index a807d277a7..ab198643ed 100644 --- 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 @@ -2,11 +2,6 @@ apiVersion: v1 kind: Service metadata: name: "" - ownerReferences: # used for finding which Tomcat does this Deployment belong to - - apiVersion: apps/v1 - kind: Tomcat - name: "" - uid: "" spec: selector: app: "" 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 index 042f4973cf..a38c705898 100644 --- 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 @@ -8,13 +8,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import io.fabric8.kubernetes.api.model.*; -import io.fabric8.kubernetes.client.*; -import io.fabric8.kubernetes.client.extended.run.RunConfigBuilder; -import io.javaoperatorsdk.operator.config.runtime.DefaultConfigurationService; +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.E2EOperatorExtension; -import io.javaoperatorsdk.operator.junit.OperatorExtension; +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; @@ -23,15 +24,15 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.notNullValue; -public class TomcatOperatorE2E { +class TomcatOperatorE2E { - final static Logger log = LoggerFactory.getLogger(TomcatOperatorE2E.class); + static final Logger log = LoggerFactory.getLogger(TomcatOperatorE2E.class); - final static KubernetesClient client = new DefaultKubernetesClient(); + static final KubernetesClient client = new DefaultKubernetesClient(); public TomcatOperatorE2E() throws FileNotFoundException {} - final static int tomcatReplicas = 2; + static final int tomcatReplicas = 2; boolean isLocal() { String deployment = System.getProperty("test.deployment"); @@ -41,25 +42,25 @@ boolean isLocal() { } @RegisterExtension - AbstractOperatorExtension operator = isLocal() ? OperatorExtension.builder() - .waitForNamespaceDeletion(false) - .withConfigurationService(DefaultConfigurationService.instance()) - .withReconciler(new TomcatReconciler(client)) - .withReconciler(new WebappReconciler(client)) - .build() - : E2EOperatorExtension.builder() - .waitForNamespaceDeletion(false) - .withConfigurationService(DefaultConfigurationService.instance()) - .withOperatorDeployment( - client.load(new FileInputStream("k8s/operator.yaml")).get()) - .build(); + 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.setMetadata( + new ObjectMetaBuilder() + .withName("test-tomcat1") + .withNamespace(operator.getNamespace()) + .build()); tomcat.setSpec(new TomcatSpec()); tomcat.getSpec().setReplicas(tomcatReplicas); tomcat.getSpec().setVersion(9); @@ -68,10 +69,11 @@ Tomcat getTomcat() { Webapp getWebapp() { Webapp webapp1 = new Webapp(); - webapp1.setMetadata(new ObjectMetaBuilder() - .withName("test-webapp1") - .withNamespace(operator.getNamespace()) - .build()); + 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()); @@ -80,71 +82,58 @@ Webapp getWebapp() { } @Test - public void 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()).create(tomcat); + tomcatClient.inNamespace(operator.getNamespace()).resource(tomcat).create(); log.info("Creating test Webapp object: {}", webapp1); - webappClient.inNamespace(operator.getNamespace()).create(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())); - }); + 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 { - - log.info("Starting curl Pod to test if webapp was deployed correctly"); - Pod curlPod = client.run().inNamespace(operator.getNamespace()) - .withRunConfig(new RunConfigBuilder() - .withArgs("-s", "-o", "/dev/null", "-w", "%{http_code}", url) - .withName("curl") - .withImage("curlimages/curl:7.78.0") - .withRestartPolicy("Never") - .build()) - .done(); - log.info("Waiting for curl Pod to finish running"); - await("wait-for-curl-pod-run").atMost(2, MINUTES) - .until(() -> { - String phase = - client.pods().inNamespace(operator.getNamespace()).withName("curl").get() - .getStatus().getPhase(); - return phase.equals("Succeeded") || phase.equals("Failed"); + 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); + } }); - String curlOutput = - client.pods().inNamespace(operator.getNamespace()) - .withName(curlPod.getMetadata().getName()).getLog(); - log.info("Output from curl: '{}'", curlOutput); - assertThat(curlOutput, equalTo("200")); - } catch (KubernetesClientException ex) { - throw new AssertionError(ex); - } finally { - log.info("Deleting curl Pod"); - client.pods().inNamespace(operator.getNamespace()).withName("curl").delete(); - await("wait-for-curl-pod-stop").atMost(1, MINUTES) - .until(() -> client.pods().inNamespace(operator.getNamespace()).withName("curl") - .get() == null); - } - }); + 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/webpage/README.md b/sample-operators/webpage/README.md index ba17b7d962..7718d0f2f3 100644 --- a/sample-operators/webpage/README.md +++ b/sample-operators/webpage/README.md @@ -1,7 +1,7 @@ -# WebServer Operator +# 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 webserver resource, which mainly contains a +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. @@ -23,12 +23,26 @@ spec: ``` + +### 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 k8s/crd.yaml` +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` @@ -42,12 +56,23 @@ page. Otherwise you can change the service to a LoadBalancer (e.g on a public cl 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 k8s/crd.yaml` +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 index 926b2c31e2..36d89054da 100644 --- a/sample-operators/webpage/k8s/operator.yaml +++ b/sample-operators/webpage/k8s/operator.yaml @@ -1,50 +1,46 @@ apiVersion: v1 -kind: Namespace -metadata: - name: webserver-operator - ---- -apiVersion: v1 kind: ServiceAccount metadata: - name: webserver-operator - namespace: webserver-operator + name: webpage-operator --- apiVersion: apps/v1 kind: Deployment metadata: - name: webserver-operator - namespace: webserver-operator + name: webpage-operator spec: selector: matchLabels: - app: webserver-operator + app: webpage-operator replicas: 1 template: metadata: labels: - app: webserver-operator + app: webpage-operator spec: - serviceAccountName: webserver-operator + serviceAccountName: webpage-operator containers: - name: operator - image: webserver-operator + image: webpage-operator imagePullPolicy: Never ports: - containerPort: 80 - readinessProbe: + startupProbe: httpGet: - path: /health + path: /startup port: 8080 initialDelaySeconds: 1 + periodSeconds: 2 timeoutSeconds: 1 + failureThreshold: 10 livenessProbe: httpGet: - path: /health + path: /healthz port: 8080 - initialDelaySeconds: 30 + initialDelaySeconds: 5 timeoutSeconds: 1 + periodSeconds: 2 + failureThreshold: 3 --- apiVersion: rbac.authorization.k8s.io/v1 @@ -53,18 +49,18 @@ metadata: name: operator-admin subjects: - kind: ServiceAccount - name: webserver-operator - namespace: webserver-operator + name: webpage-operator + namespace: default roleRef: kind: ClusterRole - name: webserver-operator + name: webpage-operator apiGroup: "" --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: webserver-operator + name: webpage-operator rules: - apiGroups: - "" @@ -92,7 +88,14 @@ rules: - apiGroups: - "sample.javaoperatorsdk" resources: - - webservers - - webservers/status + - webpages + - webpages/status verbs: - '*' +- apiGroups: + - "networking.k8s.io" + resources: + - ingresses + verbs: + - '*' + diff --git a/sample-operators/webpage/k8s/webpage.yaml b/sample-operators/webpage/k8s/webpage.yaml index 382da972cb..1aa41ff67a 100644 --- a/sample-operators/webpage/k8s/webpage.yaml +++ b/sample-operators/webpage/k8s/webpage.yaml @@ -1,14 +1,18 @@ apiVersion: "sample.javaoperatorsdk/v1" 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 - Hello World! + Hello World! diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index 4ec7713b93..55eafa8490 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -1,68 +1,92 @@ - - 4.0.0 + + 4.0.0 - - io.javaoperatorsdk - sample-operators - 2.1.2-SNAPSHOT - - - webpage - Operator SDK - Samples - WebPage - Provisions an nginx Webserver based on a CRD with give html - jar + + io.javaoperatorsdk + sample-operators + 5.1.5-SNAPSHOT + - - 11 - 11 - 3.2.0 - + sample-webpage-operator + jar + Operator SDK - Samples - WebPage + Provisions an nginx Webserver based on a CRD with give html + - - io.javaoperatorsdk - operator-framework - 2.1.2-SNAPSHOT - - - org.apache.logging.log4j - log4j-slf4j-impl - - - org.takes - takes - 1.19 - - - io.fabric8 - crd-generator-apt - provided - + + io.javaoperatorsdk + operator-framework-bom + ${project.version} + pom + import + - - - - com.google.cloud.tools - jib-maven-plugin - ${jib-maven-plugin.version} - - - gcr.io/distroless/java:11 - - - webserver-operator - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.9.0 - - - + + + + + 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 + + + + + + - \ No newline at end of file + 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 index e2d3f3c1dd..94efc05baa 100644 --- 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 @@ -1,6 +1,6 @@ package io.javaoperatorsdk.operator.sample; -public class ErrorSimulationException extends RuntimeException { +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/WebPage.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPage.java deleted file mode 100644 index 6c10ffe3af..0000000000 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPage.java +++ /dev/null @@ -1,17 +0,0 @@ -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("sample.javaoperatorsdk") -@Version("v1") -public class WebPage extends CustomResource - implements Namespaced { - - @Override - protected WebPageStatus initStatus() { - return new WebPageStatus(); - } -} 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 index 768fd26a72..1885e2d3b3 100644 --- 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 @@ -1,35 +1,46 @@ package io.javaoperatorsdk.operator.sample; import java.io.IOException; +import java.net.InetSocketAddress; 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.fabric8.kubernetes.client.Config; -import io.fabric8.kubernetes.client.ConfigBuilder; -import io.fabric8.kubernetes.client.DefaultKubernetesClient; -import io.fabric8.kubernetes.client.KubernetesClient; + import io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.config.runtime.DefaultConfigurationService; +import io.javaoperatorsdk.operator.sample.probes.LivenessHandler; +import io.javaoperatorsdk.operator.sample.probes.StartupHandler; -public class WebPageOperator { +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!"); - Config config = new ConfigBuilder().withNamespace(null).build(); - KubernetesClient client = new DefaultKubernetesClient(config); - Operator operator = new Operator(client, DefaultConfigurationService.instance()); - operator.register(new WebPageReconciler(client)); - operator.installShutdownHook(); + 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(); - new FtBasic(new TkFork(new FkRegex("/health", "ALL GOOD!")), 8080).start(Exit.NEVER); + 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 index e02c438417..bdeef954a2 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java @@ -1,10 +1,9 @@ package io.javaoperatorsdk.operator.sample; -import java.io.IOException; -import java.io.InputStream; import java.util.HashMap; +import java.util.List; import java.util.Map; -import java.util.Optional; +import java.util.Objects; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; @@ -12,174 +11,256 @@ import io.fabric8.kubernetes.api.model.*; import io.fabric8.kubernetes.api.model.apps.Deployment; -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 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, ErrorStatusHandler { +public class WebPageReconciler implements Reconciler { + + public static final String INDEX_HTML = "index.html"; - private final Logger log = LoggerFactory.getLogger(getClass()); + private static final Logger log = LoggerFactory.getLogger(WebPageReconciler.class); - private final KubernetesClient kubernetesClient; + public WebPageReconciler() {} - public WebPageReconciler(KubernetesClient kubernetesClient) { - this.kubernetesClient = kubernetesClient; + @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) { - if (webPage.getSpec().getHtml().contains("error")) { - throw new ErrorSimulationException("Simulating error"); + 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); - Map data = new HashMap<>(); - data.put("index.html", webPage.getSpec().getHtml()); + ConfigMap desiredHtmlConfigMap = makeDesiredHtmlConfigMap(ns, configMapName, webPage); + Deployment desiredDeployment = + makeDesiredDeployment(webPage, deploymentName, ns, configMapName); + Service desiredService = makeDesiredService(webPage, ns, desiredDeployment); - ConfigMap htmlConfigMap = - new ConfigMapBuilder() - .withMetadata( - new ObjectMetaBuilder() - .withName(configMapName) - .withNamespace(ns) - .build()) - .withData(data) - .build(); + 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(); + } - Deployment deployment = loadYaml(Deployment.class, "deployment.yaml"); - deployment.getMetadata().setName(deploymentName); - deployment.getMetadata().setNamespace(ns); - deployment.getSpec().getSelector().getMatchLabels().put("app", deploymentName); + 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(); + } - deployment - .getSpec() - .getTemplate() - .getMetadata() - .getLabels() - .put("app", deploymentName); - deployment - .getSpec() - .getTemplate() - .getSpec() - .getVolumes() - .get(0) - .setConfigMap( - new ConfigMapVolumeSourceBuilder().withName(configMapName).build()); - - Service service = loadYaml(Service.class, "service.yaml"); - service.getMetadata().setName(serviceName(webPage)); - 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); + 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(); } - 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(webPage)) - .delete(); + 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(); } - WebPageStatus status = new WebPageStatus(); - status.setHtmlConfigMap(htmlConfigMap.getMetadata().getName()); - status.setAreWeGood("Yes!"); - status.setErrorMessage(null); - webPage.setStatus(status); + return UpdateControl.patchStatus( + createWebPageForStatusUpdate(webPage, desiredHtmlConfigMap.getMetadata().getName())); + } - return UpdateControl.updateStatus(webPage); + 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); } - @Override - public DeleteControl cleanup(WebPage nginx, Context context) { - log.info("Cleaning up 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(); + 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()); } + } - 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(); + private boolean match(Service desiredService, Service service) { + if (service == null) { + return false; } + return desiredService.getSpec().getSelector().equals(service.getSpec().getSelector()); + } - log.info("Deleting Service {}", serviceName(nginx)); - ServiceResource service = - kubernetesClient - .services() - .inNamespace(nginx.getMetadata().getNamespace()) - .withName(serviceName(nginx)); - if (service.get() != null) { - service.delete(); + private boolean match(ConfigMap desiredHtmlConfigMap, ConfigMap existingConfigMap) { + if (existingConfigMap == null) { + return false; + } else { + return desiredHtmlConfigMap.getData().equals(existingConfigMap.getData()); } - return DeleteControl.defaultDelete(); } - private static String configMapName(WebPage nginx) { - return nginx.getMetadata().getName() + "-html"; + 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 static String deploymentName(WebPage nginx) { - return nginx.getMetadata().getName(); + 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 static String serviceName(WebPage nginx) { - return nginx.getMetadata().getName(); + 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; } - 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 static Map lowLevelLabel() { + Map labels = new HashMap<>(); + labels.put(SELECTOR, "true"); + return labels; } @Override - public Optional updateErrorStatus(WebPage resource, RetryInfo retryInfo, - RuntimeException e) { - resource.getStatus().setErrorMessage("Error: " + e.getMessage()); - return Optional.of(resource); + 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/WebPageSpec.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageSpec.java deleted file mode 100644 index db50be6c2d..0000000000 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageSpec.java +++ /dev/null @@ -1,14 +0,0 @@ -package io.javaoperatorsdk.operator.sample; - -public class WebPageSpec { - - private String html; - - public String getHtml() { - return html; - } - - public void setHtml(String html) { - this.html = html; - } -} 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/WebPageStatus.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageStatus.java deleted file mode 100644 index 2b2e2a23c8..0000000000 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageStatus.java +++ /dev/null @@ -1,37 +0,0 @@ -package io.javaoperatorsdk.operator.sample; - -import io.javaoperatorsdk.operator.api.ObservedGenerationAwareStatus; - -public class WebPageStatus extends ObservedGenerationAwareStatus { - - private String htmlConfigMap; - - private String areWeGood; - - private String errorMessage; - - 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 getErrorMessage() { - return errorMessage; - } - - public WebPageStatus setErrorMessage(String errorMessage) { - this.errorMessage = errorMessage; - return this; - } -} 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/sample-operators/webpage/src/main/resources/io/javaoperatorsdk/operator/sample/html-configmap.yaml b/sample-operators/webpage/src/main/resources/io/javaoperatorsdk/operator/sample/html-configmap.yaml deleted file mode 100644 index 8314c5b927..0000000000 --- a/sample-operators/webpage/src/main/resources/io/javaoperatorsdk/operator/sample/html-configmap.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: ConfigMap -apiVersion: v1 -metadata: - name: "" -data: - html: "" \ No newline at end of file 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 index 6fd1ed93e9..64a49bdc3e 100644 --- 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 @@ -1,19 +1,17 @@ -apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2 -kind: Deployment +apiVersion: networking.k8s.io/v1 +kind: Ingress metadata: - name: + name: "" + annotations: + nginx.ingress.kubernetes.io/rewrite-target: /$1 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 + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: "" + port: + number: 80 \ No newline at end of file diff --git a/sample-operators/webpage/src/main/resources/log4j2.xml b/sample-operators/webpage/src/main/resources/log4j2.xml index 5b794e7de3..3e92919d3b 100644 --- a/sample-operators/webpage/src/main/resources/log4j2.xml +++ b/sample-operators/webpage/src/main/resources/log4j2.xml @@ -2,7 +2,7 @@ - + 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/smoke-test-samples/README.md b/smoke-test-samples/README.md deleted file mode 100644 index 12daaa70f6..0000000000 --- a/smoke-test-samples/README.md +++ /dev/null @@ -1,4 +0,0 @@ -This samples folder contains simple artificial samples used for testing the framework rather than -showing off its real-world usage. - -More realistic samples can be found in the `sample-operators` directory. diff --git a/smoke-test-samples/common/crd/test_object.yaml b/smoke-test-samples/common/crd/test_object.yaml deleted file mode 100644 index d897b550ef..0000000000 --- a/smoke-test-samples/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 diff --git a/smoke-test-samples/common/pom.xml b/smoke-test-samples/common/pom.xml deleted file mode 100644 index 6cf1f8d991..0000000000 --- a/smoke-test-samples/common/pom.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - 4.0.0 - - - io.javaoperatorsdk - java-operator-sdk-smoke-test-samples - 2.1.2-SNAPSHOT - - - operator-framework-smoke-test-samples-common - Operator SDK - Smoke Test Samples - Common Files - Files shared between some of the samples - jar - - - - io.javaoperatorsdk - operator-framework - compile - - - io.fabric8 - crd-generator-apt - compile - - - - org.apache.logging.log4j - log4j-slf4j-impl - - - org.apache.logging.log4j - log4j-core - - - - diff --git a/smoke-test-samples/common/src/main/java/io/javaoperatorsdk/operator/sample/CustomService.java b/smoke-test-samples/common/src/main/java/io/javaoperatorsdk/operator/sample/CustomService.java deleted file mode 100644 index d67c7e6955..0000000000 --- a/smoke-test-samples/common/src/main/java/io/javaoperatorsdk/operator/sample/CustomService.java +++ /dev/null @@ -1,13 +0,0 @@ -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.ShortNames; -import io.fabric8.kubernetes.model.annotation.Version; - -@Group("sample.javaoperatorsdk") -@Version("v1") -@ShortNames("cs") -public class CustomService extends CustomResource implements Namespaced { -} diff --git a/smoke-test-samples/common/src/main/java/io/javaoperatorsdk/operator/sample/CustomServiceReconciler.java b/smoke-test-samples/common/src/main/java/io/javaoperatorsdk/operator/sample/CustomServiceReconciler.java deleted file mode 100644 index 5e3348fadd..0000000000 --- a/smoke-test-samples/common/src/main/java/io/javaoperatorsdk/operator/sample/CustomServiceReconciler.java +++ /dev/null @@ -1,61 +0,0 @@ -package io.javaoperatorsdk.operator.sample; - -import java.util.Collections; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.fabric8.kubernetes.api.model.ServiceBuilder; -import io.fabric8.kubernetes.api.model.ServicePort; -import io.fabric8.kubernetes.api.model.ServiceSpec; -import io.fabric8.kubernetes.client.DefaultKubernetesClient; -import io.fabric8.kubernetes.client.KubernetesClient; -import io.javaoperatorsdk.operator.api.reconciler.*; -import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; - -/** A very simple sample controller that creates a service with a label. */ -@ControllerConfiguration -public class CustomServiceReconciler implements Reconciler { - - private static final Logger log = LoggerFactory.getLogger(CustomServiceReconciler.class); - - private final KubernetesClient kubernetesClient; - - public CustomServiceReconciler() { - this(new DefaultKubernetesClient()); - } - - public CustomServiceReconciler(KubernetesClient kubernetesClient) { - this.kubernetesClient = kubernetesClient; - } - - @Override - public DeleteControl cleanup(CustomService resource, Context context) { - log.info("Cleaning up for: {}", resource.getMetadata().getName()); - return Reconciler.super.cleanup(resource, context); - } - - @Override - public UpdateControl reconcile( - CustomService resource, Context context) { - log.info("Reconciling: {}", resource.getMetadata().getName()); - - ServicePort servicePort = new ServicePort(); - servicePort.setPort(8080); - ServiceSpec serviceSpec = new ServiceSpec(); - serviceSpec.setPorts(Collections.singletonList(servicePort)); - - kubernetesClient - .services() - .inNamespace(resource.getMetadata().getNamespace()) - .createOrReplace( - new ServiceBuilder() - .withNewMetadata() - .withName(resource.getSpec().getName()) - .addToLabels("testLabel", resource.getSpec().getLabel()) - .endMetadata() - .withSpec(serviceSpec) - .build()); - return UpdateControl.updateResource(resource); - } -} diff --git a/smoke-test-samples/common/src/main/java/io/javaoperatorsdk/operator/sample/ServiceSpec.java b/smoke-test-samples/common/src/main/java/io/javaoperatorsdk/operator/sample/ServiceSpec.java deleted file mode 100644 index f4a3452b35..0000000000 --- a/smoke-test-samples/common/src/main/java/io/javaoperatorsdk/operator/sample/ServiceSpec.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.javaoperatorsdk.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/smoke-test-samples/common/src/main/resources/log4j2.xml b/smoke-test-samples/common/src/main/resources/log4j2.xml deleted file mode 100644 index d6869ee67c..0000000000 --- a/smoke-test-samples/common/src/main/resources/log4j2.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/smoke-test-samples/pom.xml b/smoke-test-samples/pom.xml deleted file mode 100644 index 8a9f424049..0000000000 --- a/smoke-test-samples/pom.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - 4.0.0 - - - io.javaoperatorsdk - java-operator-sdk - 2.1.2-SNAPSHOT - - - java-operator-sdk-smoke-test-samples - Operator SDK - Smoke Test Samples - Samples to manually smoke the sdk - pom - - - common - pure-java - spring-boot-plain - - - - - - org.apache.maven.plugins - maven-deploy-plugin - ${maven-deploy-plugin.version} - - true - - - - - - diff --git a/smoke-test-samples/pure-java/pom.xml b/smoke-test-samples/pure-java/pom.xml deleted file mode 100644 index f56c811cbd..0000000000 --- a/smoke-test-samples/pure-java/pom.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - 4.0.0 - - - io.javaoperatorsdk - java-operator-sdk-smoke-test-samples - 2.1.2-SNAPSHOT - - - operator-framework-smoke-test-samples-pure-java - Operator SDK - Smoke Test Samples - Pure Java - Sample usage with pure java app - jar - - - - io.javaoperatorsdk - operator-framework-smoke-test-samples-common - ${project.version} - - - - diff --git a/smoke-test-samples/pure-java/src/main/java/io/javaoperatorsdk/operator/sample/PureJavaApplicationRunner.java b/smoke-test-samples/pure-java/src/main/java/io/javaoperatorsdk/operator/sample/PureJavaApplicationRunner.java deleted file mode 100644 index 2cc212340b..0000000000 --- a/smoke-test-samples/pure-java/src/main/java/io/javaoperatorsdk/operator/sample/PureJavaApplicationRunner.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.javaoperatorsdk.operator.sample; - -import io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; -import io.javaoperatorsdk.operator.config.runtime.DefaultConfigurationService; - -public class PureJavaApplicationRunner { - - public static void main(String[] args) { - Operator operator = - new Operator(ConfigurationServiceOverrider.override(DefaultConfigurationService.instance()) - .withConcurrentReconciliationThreads(2) - .build()); - operator.register(new CustomServiceReconciler()); - operator.start(); - } -} diff --git a/smoke-test-samples/spring-boot-plain/pom.xml b/smoke-test-samples/spring-boot-plain/pom.xml deleted file mode 100644 index f97e16f347..0000000000 --- a/smoke-test-samples/spring-boot-plain/pom.xml +++ /dev/null @@ -1,76 +0,0 @@ - - - 4.0.0 - - - io.javaoperatorsdk - java-operator-sdk-smoke-test-samples - 2.1.2-SNAPSHOT - - - operator-framework-smoke-test-samples-spring-boot - Operator SDK - Smoke Test Samples - Spring Boot - Sample usage with Spring Boot - jar - - - - io.javaoperatorsdk - operator-framework-smoke-test-samples-common - ${project.version} - - - org.springframework.boot - spring-boot-starter-log4j2 - - - org.springframework.boot - spring-boot-starter - - - org.springframework.boot - spring-boot-starter-logging - - - - - org.springframework.boot - spring-boot-starter-test - test - - - junit - junit - - - - - org.apache.logging.log4j - log4j-api - ${log4j.version} - - - - - - - org.springframework.boot - spring-boot-dependencies - ${spring-boot.version} - pom - import - - - - - - - - org.springframework.boot - spring-boot-maven-plugin - ${spring-boot.version} - - - - diff --git a/smoke-test-samples/spring-boot-plain/src/main/java/io/javaoperatorsdk/operator/sample/Config.java b/smoke-test-samples/spring-boot-plain/src/main/java/io/javaoperatorsdk/operator/sample/Config.java deleted file mode 100644 index 07dd4b50c0..0000000000 --- a/smoke-test-samples/spring-boot-plain/src/main/java/io/javaoperatorsdk/operator/sample/Config.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.javaoperatorsdk.operator.sample; - -import java.util.List; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.api.reconciler.Reconciler; -import io.javaoperatorsdk.operator.config.runtime.DefaultConfigurationService; - -@Configuration -public class Config { - - @Bean - public CustomServiceReconciler customServiceController() { - return new CustomServiceReconciler(); - } - - // Register all controller beans - @Bean(initMethod = "start", destroyMethod = "stop") - public Operator operator(List controllers) { - Operator operator = new Operator(DefaultConfigurationService.instance()); - controllers.forEach(operator::register); - return operator; - } -} diff --git a/smoke-test-samples/spring-boot-plain/src/main/java/io/javaoperatorsdk/operator/sample/SpringBootStarterSampleApplication.java b/smoke-test-samples/spring-boot-plain/src/main/java/io/javaoperatorsdk/operator/sample/SpringBootStarterSampleApplication.java deleted file mode 100644 index 97533f858a..0000000000 --- a/smoke-test-samples/spring-boot-plain/src/main/java/io/javaoperatorsdk/operator/sample/SpringBootStarterSampleApplication.java +++ /dev/null @@ -1,12 +0,0 @@ -package io.javaoperatorsdk.operator.sample; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class SpringBootStarterSampleApplication { - - public static void main(String[] args) { - SpringApplication.run(SpringBootStarterSampleApplication.class, args); - } -} diff --git a/smoke-test-samples/spring-boot-plain/src/main/resources/application.yaml b/smoke-test-samples/spring-boot-plain/src/main/resources/application.yaml deleted file mode 100644 index e5ed78803c..0000000000 --- a/smoke-test-samples/spring-boot-plain/src/main/resources/application.yaml +++ /dev/null @@ -1,5 +0,0 @@ -javaoperatorsdk: - controllers: - customservicecontroller: - retry: - maxAttempts: 3 \ No newline at end of file