From 4f23d14f43c26854c601bb0118f1b8c7a4986784 Mon Sep 17 00:00:00 2001 From: Safwan Date: Mon, 15 Jun 2026 18:06:14 +0500 Subject: [PATCH] merge latest master in v2 --- .github/actions/loadtest/action.yml | 276 +++ .github/workflows/init-branch-release.yaml | 4 +- .github/workflows/loadtest.yml | 112 + .github/workflows/pull_request-helm.yaml | 19 +- .github/workflows/pull_request.yaml | 71 +- .github/workflows/pull_request_docs.yaml | 33 - .github/workflows/push-helm-chart.yaml | 23 +- .github/workflows/push-pr-image.yaml | 23 +- .github/workflows/push.yaml | 77 +- .github/workflows/release-helm-chart.yaml | 4 +- .github/workflows/release.yaml | 55 +- .../reloader-enterprise-published.yml | 9 +- .../reloader-enterprise-unpublished.yml | 9 +- .gitignore | 5 + .golangci.yml | 2 +- CLAUDE.md | 300 +++ Dockerfile | 11 +- Dockerfile-docs | 35 - Dockerfile.ubi | 18 +- Makefile | 129 +- README.md | 114 +- VERSION | 2 +- adopters/ADOPTERS.md | 74 + adopters/logos/.gitkeep | 1 + adopters/logos/exelient-ab.svg | 1 + adopters/logos/stakater-cloud.svg | 30 + .../kubernetes/chart/reloader/Chart.yaml | 4 +- .../kubernetes/chart/reloader/README.md | 24 +- .../chart/reloader/templates/clusterrole.yaml | 11 + .../chart/reloader/templates/deployment.yaml | 14 +- .../chart/reloader/templates/role.yaml | 11 + .../kubernetes/chart/reloader/values.yaml | 8 +- .../kubernetes/manifests/deployment.yaml | 2 +- deployments/kubernetes/reloader.yaml | 2 +- docs-nginx.conf | 11 - docs/Alerting.md | 18 - docs/Container Build.md | 53 - docs/Helm2-to-Helm3.md | 68 - docs/How-it-works.md | 93 - docs/Reloader-vs-ConfigmapController.md | 11 - docs/Reloader-vs-k8s-trigger-controller.md | 46 - docs/Reloader-with-Sealed-Secrets.md | 14 - docs/Verify-Reloader-Working.md | 75 - docs/index.md | 26 - go.mod | 151 +- go.sum | 300 +-- internal/pkg/metrics/prometheus.go | 1 + scripts/e2e-cluster-cleanup.sh | 283 +++ scripts/e2e-cluster-setup.sh | 351 +++ scripts/release.sh | 252 ++ test/e2e/README.md | 124 + test/e2e/advanced/advanced_suite_test.go | 93 + test/e2e/advanced/job_reload_test.go | 248 ++ test/e2e/advanced/multi_container_test.go | 219 ++ test/e2e/advanced/regex_test.go | 134 ++ .../e2e/annotations/annotations_suite_test.go | 108 + test/e2e/annotations/auto_reload_test.go | 408 ++++ test/e2e/annotations/combination_test.go | 346 +++ test/e2e/annotations/exclude_test.go | 385 ++++ test/e2e/annotations/pause_period_test.go | 143 ++ test/e2e/annotations/resource_ignore_test.go | 94 + test/e2e/annotations/search_match_test.go | 215 ++ test/e2e/argo/argo_suite_test.go | 89 + test/e2e/argo/rollout_test.go | 89 + test/e2e/core/core_suite_test.go | 112 + test/e2e/core/reference_methods_test.go | 540 +++++ test/e2e/core/workloads_test.go | 1756 ++++++++++++++ test/e2e/csi/csi_suite_test.go | 95 + test/e2e/csi/csi_test.go | 330 +++ test/e2e/flags/auto_reload_all_test.go | 107 + test/e2e/flags/flags_suite_test.go | 97 + test/e2e/flags/ignore_resources_test.go | 188 ++ test/e2e/flags/ignored_workloads_test.go | 157 ++ test/e2e/flags/namespace_ignore_test.go | 115 + test/e2e/flags/namespace_selector_test.go | 116 + test/e2e/flags/reload_on_create_test.go | 142 ++ test/e2e/flags/reload_on_delete_test.go | 153 ++ test/e2e/flags/resource_selector_test.go | 112 + test/e2e/flags/watch_globally_test.go | 164 ++ test/e2e/utils/accessors.go | 176 ++ test/e2e/utils/annotations.go | 241 ++ test/e2e/utils/annotations_test.go | 303 +++ test/e2e/utils/argo.go | 120 + test/e2e/utils/conditions.go | 258 +++ test/e2e/utils/csi.go | 338 +++ test/e2e/utils/helm.go | 219 ++ test/e2e/utils/helm_test.go | 172 ++ test/e2e/utils/openshift.go | 23 + test/e2e/utils/podspec.go | 306 +++ test/e2e/utils/rand.go | 26 + test/e2e/utils/rand_test.go | 122 + test/e2e/utils/resources.go | 977 ++++++++ test/e2e/utils/test_helpers.go | 12 + test/e2e/utils/test_helpers_test.go | 143 ++ test/e2e/utils/testenv.go | 265 +++ test/e2e/utils/utils.go | 87 + test/e2e/utils/watch.go | 231 ++ test/e2e/utils/workload_adapter.go | 185 ++ test/e2e/utils/workload_argo.go | 158 ++ test/e2e/utils/workload_cronjob.go | 108 + test/e2e/utils/workload_daemonset.go | 108 + test/e2e/utils/workload_deployment.go | 128 ++ test/e2e/utils/workload_job.go | 120 + test/e2e/utils/workload_openshift.go | 149 ++ test/e2e/utils/workload_statefulset.go | 108 + test/loadtest/README.md | 544 +++++ test/loadtest/cmd/loadtest/main.go | 7 + test/loadtest/go.mod | 52 + test/loadtest/go.sum | 160 ++ test/loadtest/internal/cluster/kind.go | 314 +++ test/loadtest/internal/cmd/report.go | 860 +++++++ test/loadtest/internal/cmd/root.go | 43 + test/loadtest/internal/cmd/run.go | 648 ++++++ test/loadtest/internal/cmd/summary.go | 251 ++ .../internal/prometheus/prometheus.go | 429 ++++ test/loadtest/internal/reloader/reloader.go | 271 +++ test/loadtest/internal/scenarios/scenarios.go | 2037 +++++++++++++++++ test/loadtest/manifests/prometheus.yaml | 181 ++ theme_common | 1 - theme_override/mkdocs.yml | 22 - theme_override/resources/.gitignore | 0 .../resources/assets/images/favicon.svg | 1 - 122 files changed, 20148 insertions(+), 901 deletions(-) create mode 100644 .github/actions/loadtest/action.yml create mode 100644 .github/workflows/loadtest.yml delete mode 100644 .github/workflows/pull_request_docs.yaml create mode 100644 CLAUDE.md delete mode 100644 Dockerfile-docs create mode 100644 adopters/ADOPTERS.md create mode 100644 adopters/logos/.gitkeep create mode 100644 adopters/logos/exelient-ab.svg create mode 100644 adopters/logos/stakater-cloud.svg delete mode 100644 docs-nginx.conf delete mode 100644 docs/Alerting.md delete mode 100644 docs/Container Build.md delete mode 100644 docs/Helm2-to-Helm3.md delete mode 100644 docs/How-it-works.md delete mode 100644 docs/Reloader-vs-ConfigmapController.md delete mode 100644 docs/Reloader-vs-k8s-trigger-controller.md delete mode 100644 docs/Reloader-with-Sealed-Secrets.md delete mode 100644 docs/Verify-Reloader-Working.md delete mode 100644 docs/index.md create mode 100755 scripts/e2e-cluster-cleanup.sh create mode 100755 scripts/e2e-cluster-setup.sh create mode 100755 scripts/release.sh create mode 100644 test/e2e/README.md create mode 100644 test/e2e/advanced/advanced_suite_test.go create mode 100644 test/e2e/advanced/job_reload_test.go create mode 100644 test/e2e/advanced/multi_container_test.go create mode 100644 test/e2e/advanced/regex_test.go create mode 100644 test/e2e/annotations/annotations_suite_test.go create mode 100644 test/e2e/annotations/auto_reload_test.go create mode 100644 test/e2e/annotations/combination_test.go create mode 100644 test/e2e/annotations/exclude_test.go create mode 100644 test/e2e/annotations/pause_period_test.go create mode 100644 test/e2e/annotations/resource_ignore_test.go create mode 100644 test/e2e/annotations/search_match_test.go create mode 100644 test/e2e/argo/argo_suite_test.go create mode 100644 test/e2e/argo/rollout_test.go create mode 100644 test/e2e/core/core_suite_test.go create mode 100644 test/e2e/core/reference_methods_test.go create mode 100644 test/e2e/core/workloads_test.go create mode 100644 test/e2e/csi/csi_suite_test.go create mode 100644 test/e2e/csi/csi_test.go create mode 100644 test/e2e/flags/auto_reload_all_test.go create mode 100644 test/e2e/flags/flags_suite_test.go create mode 100644 test/e2e/flags/ignore_resources_test.go create mode 100644 test/e2e/flags/ignored_workloads_test.go create mode 100644 test/e2e/flags/namespace_ignore_test.go create mode 100644 test/e2e/flags/namespace_selector_test.go create mode 100644 test/e2e/flags/reload_on_create_test.go create mode 100644 test/e2e/flags/reload_on_delete_test.go create mode 100644 test/e2e/flags/resource_selector_test.go create mode 100644 test/e2e/flags/watch_globally_test.go create mode 100644 test/e2e/utils/accessors.go create mode 100644 test/e2e/utils/annotations.go create mode 100644 test/e2e/utils/annotations_test.go create mode 100644 test/e2e/utils/argo.go create mode 100644 test/e2e/utils/conditions.go create mode 100644 test/e2e/utils/csi.go create mode 100644 test/e2e/utils/helm.go create mode 100644 test/e2e/utils/helm_test.go create mode 100644 test/e2e/utils/openshift.go create mode 100644 test/e2e/utils/podspec.go create mode 100644 test/e2e/utils/rand.go create mode 100644 test/e2e/utils/rand_test.go create mode 100644 test/e2e/utils/resources.go create mode 100644 test/e2e/utils/test_helpers.go create mode 100644 test/e2e/utils/test_helpers_test.go create mode 100644 test/e2e/utils/testenv.go create mode 100644 test/e2e/utils/utils.go create mode 100644 test/e2e/utils/watch.go create mode 100644 test/e2e/utils/workload_adapter.go create mode 100644 test/e2e/utils/workload_argo.go create mode 100644 test/e2e/utils/workload_cronjob.go create mode 100644 test/e2e/utils/workload_daemonset.go create mode 100644 test/e2e/utils/workload_deployment.go create mode 100644 test/e2e/utils/workload_job.go create mode 100644 test/e2e/utils/workload_openshift.go create mode 100644 test/e2e/utils/workload_statefulset.go create mode 100644 test/loadtest/README.md create mode 100644 test/loadtest/cmd/loadtest/main.go create mode 100644 test/loadtest/go.mod create mode 100644 test/loadtest/go.sum create mode 100644 test/loadtest/internal/cluster/kind.go create mode 100644 test/loadtest/internal/cmd/report.go create mode 100644 test/loadtest/internal/cmd/root.go create mode 100644 test/loadtest/internal/cmd/run.go create mode 100644 test/loadtest/internal/cmd/summary.go create mode 100644 test/loadtest/internal/prometheus/prometheus.go create mode 100644 test/loadtest/internal/reloader/reloader.go create mode 100644 test/loadtest/internal/scenarios/scenarios.go create mode 100644 test/loadtest/manifests/prometheus.yaml delete mode 160000 theme_common delete mode 100644 theme_override/mkdocs.yml delete mode 100644 theme_override/resources/.gitignore delete mode 100644 theme_override/resources/assets/images/favicon.svg diff --git a/.github/actions/loadtest/action.yml b/.github/actions/loadtest/action.yml new file mode 100644 index 00000000..164056d6 --- /dev/null +++ b/.github/actions/loadtest/action.yml @@ -0,0 +1,276 @@ +name: 'Reloader Load Test' +description: 'Run Reloader load tests with A/B comparison support' + +inputs: + old-ref: + description: 'Git ref for "old" version (optional, enables A/B comparison)' + required: false + default: '' + new-ref: + description: 'Git ref for "new" version (defaults to current checkout)' + required: false + default: '' + old-image: + description: 'Pre-built container image for "old" version (alternative to old-ref)' + required: false + default: '' + new-image: + description: 'Pre-built container image for "new" version (alternative to new-ref)' + required: false + default: '' + scenarios: + description: 'Scenarios to run: S1,S4,S6 or all' + required: false + default: 'S1,S4,S6' + test-type: + description: 'Test type label for summary: quick or full' + required: false + default: 'quick' + duration: + description: 'Test duration in seconds' + required: false + default: '60' + kind-cluster: + description: 'Name of existing Kind cluster (if empty, creates new one)' + required: false + default: '' + post-comment: + description: 'Post results as PR comment' + required: false + default: 'false' + pr-number: + description: 'PR number for commenting (required if post-comment is true)' + required: false + default: '' + github-token: + description: 'GitHub token for posting comments' + required: false + default: ${{ github.token }} + comment-header: + description: 'Optional header text for the comment' + required: false + default: '' + +outputs: + status: + description: 'Overall test status: pass or fail' + value: ${{ steps.run.outputs.status }} + summary: + description: 'Markdown summary of results' + value: ${{ steps.summary.outputs.summary }} + pass-count: + description: 'Number of passed scenarios' + value: ${{ steps.summary.outputs.pass_count }} + fail-count: + description: 'Number of failed scenarios' + value: ${{ steps.summary.outputs.fail_count }} + +runs: + using: 'composite' + steps: + - name: Determine images to use + id: images + shell: bash + run: | + # Determine old image + if [ -n "${{ inputs.old-image }}" ]; then + echo "old=${{ inputs.old-image }}" >> $GITHUB_OUTPUT + elif [ -n "${{ inputs.old-ref }}" ]; then + echo "old=localhost/reloader:old" >> $GITHUB_OUTPUT + echo "build_old=true" >> $GITHUB_OUTPUT + else + echo "old=" >> $GITHUB_OUTPUT + fi + + # Determine new image + if [ -n "${{ inputs.new-image }}" ]; then + echo "new=${{ inputs.new-image }}" >> $GITHUB_OUTPUT + elif [ -n "${{ inputs.new-ref }}" ]; then + echo "new=localhost/reloader:new" >> $GITHUB_OUTPUT + echo "build_new=true" >> $GITHUB_OUTPUT + else + # Default: build from current checkout + echo "new=localhost/reloader:new" >> $GITHUB_OUTPUT + echo "build_new_current=true" >> $GITHUB_OUTPUT + fi + + - name: Build old image from ref + if: steps.images.outputs.build_old == 'true' + shell: bash + run: | + CURRENT_SHA=$(git rev-parse HEAD) + git checkout ${{ inputs.old-ref }} + docker build -t localhost/reloader:old . + echo "Built old image from ref: ${{ inputs.old-ref }}" + git checkout $CURRENT_SHA + + - name: Build new image from ref + if: steps.images.outputs.build_new == 'true' + shell: bash + run: | + CURRENT_SHA=$(git rev-parse HEAD) + git checkout ${{ inputs.new-ref }} + docker build -t localhost/reloader:new . + echo "Built new image from ref: ${{ inputs.new-ref }}" + git checkout $CURRENT_SHA + + - name: Build new image from current checkout + if: steps.images.outputs.build_new_current == 'true' + shell: bash + run: | + docker build -t localhost/reloader:new . + echo "Built new image from current checkout" + + - name: Build loadtest binary + shell: bash + run: | + cd ${{ github.workspace }}/test/loadtest + go build -o loadtest ./cmd/loadtest + + - name: Determine cluster name + id: cluster + shell: bash + run: | + if [ -n "${{ inputs.kind-cluster }}" ]; then + echo "name=${{ inputs.kind-cluster }}" >> $GITHUB_OUTPUT + echo "skip=true" >> $GITHUB_OUTPUT + else + echo "name=reloader-loadtest" >> $GITHUB_OUTPUT + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - name: Load images into Kind + shell: bash + run: | + CLUSTER="${{ steps.cluster.outputs.name }}" + + if [ -n "${{ steps.images.outputs.old }}" ]; then + echo "Loading old image: ${{ steps.images.outputs.old }}" + kind load docker-image "${{ steps.images.outputs.old }}" --name "$CLUSTER" || true + fi + + echo "Loading new image: ${{ steps.images.outputs.new }}" + kind load docker-image "${{ steps.images.outputs.new }}" --name "$CLUSTER" || true + + - name: Run load tests + id: run + shell: bash + run: | + cd ${{ github.workspace }}/test/loadtest + + ARGS="--new-image=${{ steps.images.outputs.new }}" + ARGS="$ARGS --scenario=${{ inputs.scenarios }}" + ARGS="$ARGS --duration=${{ inputs.duration }}" + ARGS="$ARGS --cluster-name=${{ steps.cluster.outputs.name }}" + ARGS="$ARGS --skip-image-load" + + if [ -n "${{ steps.images.outputs.old }}" ]; then + ARGS="$ARGS --old-image=${{ steps.images.outputs.old }}" + fi + + if [ "${{ steps.cluster.outputs.skip }}" = "true" ]; then + ARGS="$ARGS --skip-cluster" + fi + + echo "Running: ./loadtest run $ARGS" + if ./loadtest run $ARGS; then + echo "status=pass" >> $GITHUB_OUTPUT + else + echo "status=fail" >> $GITHUB_OUTPUT + fi + + - name: Generate summary + id: summary + shell: bash + run: | + cd ${{ github.workspace }}/test/loadtest + + # Generate markdown summary + ./loadtest summary \ + --results-dir=./results \ + --test-type=${{ inputs.test-type }} \ + --format=markdown > summary.md 2>/dev/null || true + + # Output to GitHub Step Summary + cat summary.md >> $GITHUB_STEP_SUMMARY + + # Store summary for output (using heredoc for multiline) + { + echo 'summary<> $GITHUB_OUTPUT + + # Get pass/fail counts from JSON + COUNTS=$(./loadtest summary --format=json 2>/dev/null | head -20 || echo '{}') + echo "pass_count=$(echo "$COUNTS" | grep -o '"pass_count": [0-9]*' | grep -o '[0-9]*' || echo 0)" >> $GITHUB_OUTPUT + echo "fail_count=$(echo "$COUNTS" | grep -o '"fail_count": [0-9]*' | grep -o '[0-9]*' || echo 0)" >> $GITHUB_OUTPUT + + - name: Post PR comment + if: inputs.post-comment == 'true' && inputs.pr-number != '' + continue-on-error: true + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + # Untrusted/templated values are passed via env and read with process.env + # inside the script, so they are never interpolated into JS source. + env: + SUMMARY_PATH: ${{ github.workspace }}/test/loadtest/summary.md + COMMENT_HEADER: ${{ inputs.comment-header }} + RUN_STATUS: ${{ steps.run.outputs.status }} + TEST_TYPE: ${{ inputs.test-type }} + PR_NUMBER: ${{ inputs.pr-number }} + RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + github-token: ${{ inputs.github-token }} + script: | + const fs = require('fs'); + const summaryPath = process.env.SUMMARY_PATH; + let summary = 'No results available'; + try { + summary = fs.readFileSync(summaryPath, 'utf8'); + } catch (e) { + console.log('Could not read summary file:', e.message); + } + + const header = process.env.COMMENT_HEADER; + const status = process.env.RUN_STATUS; + const statusEmoji = status === 'pass' ? ':white_check_mark:' : ':x:'; + + const body = [ + header ? header : `## ${statusEmoji} Load Test Results (${process.env.TEST_TYPE})`, + '', + summary, + '', + '---', + `**Artifacts:** [Download](${process.env.RUN_URL})`, + ].join('\n'); + + try { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(process.env.PR_NUMBER), + body: body + }); + console.log('Comment posted successfully'); + } catch (error) { + if (error.status === 403) { + console.log('Could not post comment (fork PR with restricted permissions). Use /loadtest command to run with comment posting.'); + } else { + throw error; + } + } + + - name: Upload results + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: loadtest-${{ inputs.test-type }}-results + path: | + ${{ github.workspace }}/test/loadtest/results/ + retention-days: 30 + + - name: Cleanup Kind cluster (only if we created it) + if: always() && steps.cluster.outputs.skip == 'false' + shell: bash + run: | + kind delete cluster --name ${{ steps.cluster.outputs.name }} || true diff --git a/.github/workflows/init-branch-release.yaml b/.github/workflows/init-branch-release.yaml index 01c54dca..cd37bd2a 100644 --- a/.github/workflows/init-branch-release.yaml +++ b/.github/workflows/init-branch-release.yaml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5.0.0 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -57,7 +57,7 @@ jobs: git diff - name: Create pull request - uses: peter-evans/create-pull-request@v7.0.8 + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 with: commit-message: "Bump version to ${{ inputs.TARGET_VERSION }}" title: "Bump version to ${{ inputs.TARGET_VERSION }} on ${{ inputs.TARGET_BRANCH }} branch" diff --git a/.github/workflows/loadtest.yml b/.github/workflows/loadtest.yml new file mode 100644 index 00000000..03270645 --- /dev/null +++ b/.github/workflows/loadtest.yml @@ -0,0 +1,112 @@ +name: Load Test (Full) + +on: + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + loadtest: + # Only run on PR comments with /loadtest command + if: | + github.event.issue.pull_request && + contains(github.event.comment.body, '/loadtest') + runs-on: ubuntu-latest + + steps: + - name: Add reaction to comment + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'rocket' + }); + + - name: Get PR details + id: pr + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + core.setOutput('head_ref', pr.data.head.ref); + core.setOutput('head_sha', pr.data.head.sha); + core.setOutput('base_ref', pr.data.base.ref); + core.setOutput('base_sha', pr.data.base.sha); + console.log(`PR #${context.issue.number}: ${pr.data.head.ref} -> ${pr.data.base.ref}`); + + - name: Checkout PR branch + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ steps.pr.outputs.head_sha }} + fetch-depth: 0 # Full history for building from base ref + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.26' + cache: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Install kind + run: | + curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.20.0/kind-linux-amd64 + chmod +x ./kind + sudo mv ./kind /usr/local/bin/kind + + - name: Install kubectl + run: | + curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + chmod +x kubectl + sudo mv kubectl /usr/local/bin/kubectl + + - name: Run full A/B comparison load test + id: loadtest + uses: ./.github/actions/loadtest + with: + old-ref: ${{ steps.pr.outputs.base_sha }} + new-ref: ${{ steps.pr.outputs.head_sha }} + scenarios: 'all' + test-type: 'full' + post-comment: 'true' + pr-number: ${{ github.event.issue.number }} + comment-header: | + ## Load Test Results (Full A/B Comparison) + **Comparing:** `${{ steps.pr.outputs.base_ref }}` → `${{ steps.pr.outputs.head_ref }}` + **Triggered by:** @${{ github.event.comment.user.login }} + + - name: Add success reaction + if: steps.loadtest.outputs.status == 'pass' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '+1' + }); + + - name: Add failure reaction + if: steps.loadtest.outputs.status == 'fail' + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: '-1' + }); diff --git a/.github/workflows/pull_request-helm.yaml b/.github/workflows/pull_request-helm.yaml index 0edafae8..3cb5482f 100644 --- a/.github/workflows/pull_request-helm.yaml +++ b/.github/workflows/pull_request-helm.yaml @@ -14,6 +14,9 @@ env: KIND_VERSION: "0.23.0" REGISTRY: ghcr.io +# Default to no GITHUB_TOKEN permissions; each job opts into the minimum it needs. +permissions: {} + jobs: helm-chart-validation: @@ -26,21 +29,23 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: ref: ${{github.event.pull_request.head.sha}} fetch-depth: 0 # Setting up helm binary - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 with: - version: v3.11.3 + version: v3.20.2 - name: Helm chart unit tests - uses: d3adb5/helm-unittest-action@v2 + uses: d3adb5/helm-unittest-action@850bc76597579183998069830d5fa8c3ef0ea34a # v2 with: charts: deployments/kubernetes/chart/reloader + helm-version: v3.20.2 + github-token: ${{ secrets.GITHUB_TOKEN }} helm-version-validation: needs: helm-chart-validation @@ -55,7 +60,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: ref: ${{github.event.pull_request.head.sha}} fetch-depth: 0 @@ -71,13 +76,13 @@ jobs: echo "CURRENT_CHART_VERSION=$(echo ${current_chart_version})" >> $GITHUB_OUTPUT - name: Get Updated Chart version from Chart.yaml - uses: mikefarah/yq@master + uses: mikefarah/yq@751d8ad57b84f1794661bc70c0afb92a22ad7b3c # v4.53.2 id: new_chart_version with: cmd: yq e '.version' deployments/kubernetes/chart/reloader/Chart.yaml - name: Check Version - uses: aleoyakas/check-semver-increased-action@v1 + uses: aleoyakas/check-semver-increased-action@415c9c60054c2442c03478b6dd96a195deac6695 # v1 id: check-version with: current-version: ${{ steps.new_chart_version.outputs.result }} diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 3eb0f6c7..b395e313 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -9,23 +9,25 @@ on: - '**' - '!.markdownlint.yaml' - '!.vale.ini' - - '!Dockerfile-docs' - - '!docs-nginx.conf' - - '!docs/**' - - '!theme_common' - - '!theme_override' - '!deployments/kubernetes/chart/reloader/**' env: DOCKER_FILE_PATH: Dockerfile DOCKER_UBI_FILE_PATH: Dockerfile.ubi KUBERNETES_VERSION: "1.30.0" - KIND_VERSION: "0.23.0" + KIND_VERSION: "0.31.0" REGISTRY: ghcr.io + RELOADER_EDITION: oss + +# Default to no GITHUB_TOKEN permissions; each job opts into the minimum it needs. +permissions: {} jobs: qa: - uses: stakater/.github/.github/workflows/pull_request_doc_qa.yaml@v0.0.163 + permissions: + contents: read + pull-requests: write # reusable workflow posts languagetool review comments + uses: stakater/.github/.github/workflows/pull_request_doc_qa.yaml@3dfb835dba6b596fe32e1d0f5eadbb4a3a139a1c # v0.0.163 with: MD_CONFIG: .github/md_config.json DOC_SRC: README.md @@ -35,33 +37,37 @@ jobs: permissions: contents: read + pull-requests: write + issues: write runs-on: ubuntu-latest name: Build steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: ref: ${{github.event.pull_request.head.sha}} fetch-depth: 0 # Setting up helm binary - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5 with: - version: v3.11.3 + version: v3.20.2 - name: Helm chart unit tests - uses: d3adb5/helm-unittest-action@v2 + uses: d3adb5/helm-unittest-action@850bc76597579183998069830d5fa8c3ef0ea34a # v2 with: charts: deployments/kubernetes/chart/reloader + helm-version: v3.20.2 + github-token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: 'go.mod' check-latest: true - cache: true + cache: false - name: Create timestamp id: prep @@ -100,17 +106,25 @@ jobs: kind version kind version | grep -q ${KIND_VERSION} - - name: Create Kind Cluster - run: | - kind create cluster - kubectl cluster-info + - name: Create Kind Cluster and Setup E2E Dependencies + run: KIND_CLUSTER=kind make e2e-setup - - - name: Unit Tests + - name: Run unit tests run: make test - - name: E2E Tests - run: make e2e + - name: Run E2E tests + run: KIND_CLUSTER=kind make e2e + + - name: Run quick A/B load tests + uses: ./.github/actions/loadtest + with: + old-ref: ${{ github.event.pull_request.base.sha }} + # new-ref defaults to current checkout (PR branch) + scenarios: 'S1,S4,S6' + test-type: 'quick' + kind-cluster: 'kind' # Use the existing cluster created above + post-comment: 'true' + pr-number: ${{ github.event.pull_request.number }} - name: Generate Tags id: generate_tag @@ -122,10 +136,10 @@ jobs: echo "GIT_UBI_TAG=$(echo ${ubi_tag})" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Generate image repository path for ghcr registry run: | @@ -134,7 +148,7 @@ jobs: # To identify any broken changes in dockerfiles or dependencies - name: Build Docker Image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ env.DOCKER_FILE_PATH }} @@ -144,9 +158,9 @@ jobs: VERSION=merge-${{ steps.generate_tag.outputs.GIT_TAG }} COMMIT=${{github.event.pull_request.head.sha}} BUILD_DATE=${{ steps.prep.outputs.created }} + EDITION=${{ env.RELOADER_EDITION }} BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} - cache-to: type=inline platforms: linux/amd64,linux/arm,linux/arm64 tags: | ${{ env.GHCR_IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.GIT_TAG }} @@ -156,16 +170,19 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} - name: Build Docker UBI Image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ env.DOCKER_UBI_FILE_PATH }} pull: true push: false build-args: | + VERSION=merge-${{ steps.generate_tag.outputs.GIT_UBI_TAG }} + COMMIT=${{github.event.pull_request.head.sha}} + BUILD_DATE=${{ steps.prep.outputs.created }} + EDITION=${{ env.RELOADER_EDITION }} BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} BUILDER_IMAGE=${{ env.GHCR_IMAGE_REPOSITORY }}:${{ steps.highest_tag.outputs.tag }} - cache-to: type=inline platforms: linux/amd64,linux/arm64 tags: | ${{ env.GHCR_IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.GIT_UBI_TAG }} diff --git a/.github/workflows/pull_request_docs.yaml b/.github/workflows/pull_request_docs.yaml deleted file mode 100644 index dd416bd5..00000000 --- a/.github/workflows/pull_request_docs.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: Pull Request for Documentation Changes - -on: - pull_request: - branches: - - master - paths: - - '.markdownlint.yaml' - - '.vale.ini' - - 'Dockerfile-docs' - - 'docs-nginx.conf' - - 'docs/**' - - 'theme_common' - - 'theme_override' - - 'deployments/kubernetes/chart/reloader/README.md' - -jobs: - qa: - uses: stakater/.github/.github/workflows/pull_request_doc_qa.yaml@v0.0.163 - with: - MD_CONFIG: .github/md_config.json - DOC_SRC: docs - MD_LINT_CONFIG: .markdownlint.yaml - build: - uses: stakater/.github/.github/workflows/pull_request_container_build.yaml@v0.0.163 - with: - DOCKER_FILE_PATH: Dockerfile-docs - CONTAINER_REGISTRY_URL: ghcr.io/stakater - PUSH_IMAGE: false - secrets: - CONTAINER_REGISTRY_USERNAME: ${{ github.actor }} - CONTAINER_REGISTRY_PASSWORD: ${{ secrets.GHCR_TOKEN }} - SLACK_WEBHOOK_URL: ${{ secrets.STAKATER_DELIVERY_SLACK_WEBHOOK }} diff --git a/.github/workflows/push-helm-chart.yaml b/.github/workflows/push-helm-chart.yaml index fc80c05e..29b52580 100644 --- a/.github/workflows/push-helm-chart.yaml +++ b/.github/workflows/push-helm-chart.yaml @@ -17,6 +17,9 @@ env: HELM_REGISTRY_URL: "https://stakater.github.io/stakater-charts" REGISTRY: ghcr.io # container registry +# Default to no GITHUB_TOKEN permissions; each job opts into the minimum it needs. +permissions: {} + jobs: verify-and-push-helm-chart: @@ -31,7 +34,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: token: ${{ secrets.PUBLISH_TOKEN }} fetch-depth: 0 # otherwise, you will fail to push refs to dest repo @@ -39,9 +42,9 @@ jobs: # Setting up helm binary - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 with: - version: v3.11.3 + version: v3.20.2 - name: Add Stakater Helm Repo run: | @@ -54,13 +57,13 @@ jobs: echo "CURRENT_CHART_VERSION=$(echo ${current_chart_version})" >> $GITHUB_OUTPUT - name: Get Updated Chart version from Chart.yaml - uses: mikefarah/yq@master + uses: mikefarah/yq@751d8ad57b84f1794661bc70c0afb92a22ad7b3c # v4.53.2 id: new_chart_version with: cmd: yq e '.version' deployments/kubernetes/chart/reloader/Chart.yaml - name: Check Version - uses: aleoyakas/check-semver-increased-action@v1 + uses: aleoyakas/check-semver-increased-action@415c9c60054c2442c03478b6dd96a195deac6695 # v1 id: check-version with: current-version: ${{ steps.new_chart_version.outputs.result }} @@ -73,10 +76,10 @@ jobs: exit 1 - name: Install Cosign - uses: sigstore/cosign-installer@v4.0.0 + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 - name: Login to GHCR Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ${{ env.REGISTRY }} username: stakater-user @@ -92,7 +95,7 @@ jobs: run: cosign sign --yes ghcr.io/stakater/charts/reloader:${{ steps.new_chart_version.outputs.result }} - name: Publish Helm chart to gh-pages - uses: stefanprodan/helm-gh-pages@master + uses: stefanprodan/helm-gh-pages@0ad2bb377311d61ac04ad9eb6f252fb68e207260 # v1.7.0 with: branch: master repository: stakater-charts @@ -106,14 +109,14 @@ jobs: commit_email: stakater@gmail.com - name: Push new chart tag - uses: anothrNick/github-tag-action@1.75.0 + uses: anothrNick/github-tag-action@4ed44965e0db8dab2b466a16da04aec3cc312fd8 # 1.75.0 env: GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }} WITH_V: false CUSTOM_TAG: chart-v${{ steps.new_chart_version.outputs.result }} - name: Notify Slack - uses: 8398a7/action-slack@v3 + uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e # v3 if: always() # Pick up events even if the job fails or is canceled. with: status: ${{ job.status }} diff --git a/.github/workflows/push-pr-image.yaml b/.github/workflows/push-pr-image.yaml index eff22f73..88259b87 100644 --- a/.github/workflows/push-pr-image.yaml +++ b/.github/workflows/push-pr-image.yaml @@ -8,17 +8,15 @@ on: paths: - '!.markdownlint.yaml' - '!.vale.ini' - - '!Dockerfile-docs' - - '!docs-nginx.conf' - - '!docs/**' - - '!theme_common' - - '!theme_override' - '!deployments/kubernetes/chart/reloader/**' env: DOCKER_FILE_PATH: Dockerfile REGISTRY: ghcr.io +# Default to no GITHUB_TOKEN permissions; each job opts into the minimum it needs. +permissions: {} + jobs: build-and-push-pr-image: @@ -30,17 +28,17 @@ jobs: if: ${{ github.event.label.name == 'build-and-push-pr-image' }} steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: ref: ${{github.event.pull_request.head.sha}} fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: 'go.mod' check-latest: true - cache: true + cache: false - name: Install Dependencies run: | @@ -57,31 +55,30 @@ jobs: echo "GIT_TAG=$(echo ${tag})" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Generate image repository path for ghcr registry run: | echo GHCR_IMAGE_REPOSITORY=${{env.REGISTRY}}/$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV - name: Login to ghcr registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ${{env.REGISTRY}} username: stakater-user password: ${{secrets.GITHUB_TOKEN}} - name: Build Docker Image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ env.DOCKER_FILE_PATH }} pull: true push: true build-args: BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} - cache-to: type=inline platforms: linux/amd64,linux/arm,linux/arm64 tags: | ${{ env.GHCR_IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.GIT_TAG }} diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index dda9a1c1..7f2f76ed 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -15,6 +15,10 @@ env: KIND_VERSION: "0.23.0" HELM_REGISTRY_URL: "https://stakater.github.io/stakater-charts" REGISTRY: ghcr.io + RELOADER_EDITION: oss + +# Default to no GITHUB_TOKEN permissions; each job opts into the minimum it needs. +permissions: {} jobs: build: @@ -29,7 +33,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: token: ${{ secrets.PUBLISH_TOKEN }} fetch-depth: 0 # otherwise, you will fail to push refs to dest repo @@ -37,16 +41,16 @@ jobs: # Setting up helm binary - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 with: - version: v3.11.3 + version: v3.20.2 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: 'go.mod' check-latest: true - cache: true + cache: false - name: Install Dependencies run: | @@ -77,13 +81,13 @@ jobs: run: make test - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.STAKATER_DOCKERHUB_USERNAME }} password: ${{ secrets.STAKATER_DOCKERHUB_PASSWORD }} @@ -97,14 +101,18 @@ jobs: echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV - name: Build and Push Docker Image to Docker registry - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ env.DOCKER_FILE_PATH }} pull: true push: true - build-args: BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} - cache-to: type=inline + build-args: | + VERSION=merge-${{ github.event.number }} + COMMIT=${{ github.sha }} + BUILD_DATE=${{ steps.prep.outputs.created }} + EDITION=${{ env.RELOADER_EDITION }} + BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} platforms: linux/amd64,linux/arm,linux/arm64 tags: | ${{ env.DOCKER_IMAGE_REPOSITORY }}:merge-${{ github.event.number }} @@ -113,7 +121,7 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} - name: Build and Push Docker UBI Image to Docker registry - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ env.DOCKER_UBI_FILE_PATH }} @@ -122,7 +130,6 @@ jobs: build-args: | BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} BUILDER_IMAGE=${{ env.DOCKER_IMAGE_REPOSITORY }}:merge-${{ github.event.number }} - cache-to: type=inline platforms: linux/amd64,linux/arm64 tags: | ${{ env.DOCKER_IMAGE_REPOSITORY }}:merge-${{ github.event.number }}-ubi @@ -131,7 +138,7 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} - name: Login to ghcr registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ${{env.REGISTRY}} username: stakater-user @@ -142,7 +149,7 @@ jobs: echo GHCR_IMAGE_REPOSITORY=${{env.REGISTRY}}/$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV - name: Build and Push Docker Image to ghcr registry - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ env.DOCKER_FILE_PATH }} @@ -152,8 +159,8 @@ jobs: VERSION=merge-${{ github.event.number }} COMMIT=${{ github.sha }} BUILD_DATE=${{ steps.prep.outputs.created }} + EDITION=${{ env.RELOADER_EDITION }} BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} - cache-to: type=inline platforms: linux/amd64,linux/arm,linux/arm64 tags: | ${{ env.GHCR_IMAGE_REPOSITORY }}:merge-${{ github.event.number }} @@ -162,7 +169,7 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} - name: Build and Push Docker UBI Image to ghcr registry - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ env.DOCKER_UBI_FILE_PATH }} @@ -171,7 +178,6 @@ jobs: build-args: | BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} BUILDER_IMAGE=${{ env.GHCR_IMAGE_REPOSITORY }}:merge-${{ github.event.number }} - cache-to: type=inline platforms: linux/amd64,linux/arm64 tags: | ${{ env.GHCR_IMAGE_REPOSITORY }}:merge-${{ github.event.number }}-ubi @@ -179,46 +185,15 @@ jobs: org.opencontainers.image.source=${{ github.event.repository.clone_url }} org.opencontainers.image.revision=${{ github.sha }} - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - docs: - - '.markdownlint.yaml' - - '.vale.ini' - - 'Dockerfile-docs' - - 'docs-nginx.conf' - - 'docs/**' - - 'README.md' - - 'theme_common' - - 'theme_override' - - # run only if 'docs' files were changed - - name: Build and Push Docker Image for Docs to ghcr registry - if: steps.filter.outputs.docs == 'true' - uses: docker/build-push-action@v6 - with: - context: . - file: Dockerfile-docs - pull: true - push: true - build-args: BUILD_PARAMETERS=${{ env.BUILD_PARAMETERS }} - cache-to: type=inline - tags: | - ${{ env.GHCR_IMAGE_REPOSITORY }}/docs:merge-${{ github.event.number }} - labels: | - org.opencontainers.image.source=${{ github.event.repository.clone_url }} - org.opencontainers.image.revision=${{ github.sha }} - - name: Push Latest Tag - uses: anothrNick/github-tag-action@1.75.0 + uses: anothrNick/github-tag-action@4ed44965e0db8dab2b466a16da04aec3cc312fd8 # 1.75.0 env: GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }} WITH_V: false CUSTOM_TAG: merge-${{ github.event.number }} - name: Notify Slack - uses: 8398a7/action-slack@v3 + uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e # v3 if: always() # Pick up events even if the job fails or is canceled. with: status: ${{ job.status }} diff --git a/.github/workflows/release-helm-chart.yaml b/.github/workflows/release-helm-chart.yaml index 78c70636..afc39ee5 100644 --- a/.github/workflows/release-helm-chart.yaml +++ b/.github/workflows/release-helm-chart.yaml @@ -15,7 +15,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: fetch-depth: 0 @@ -30,7 +30,7 @@ jobs: --generate-notes - name: Notify Slack - uses: 8398a7/action-slack@v3 + uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e # v3 if: always() with: status: ${{ job.status }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6bd392f7..ee1154b6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -11,6 +11,10 @@ env: KUBERNETES_VERSION: "1.30.0" KIND_VERSION: "0.23.0" REGISTRY: ghcr.io + RELOADER_EDITION: oss + +# Default to no GITHUB_TOKEN permissions; each job opts into the minimum it needs. +permissions: {} jobs: release: @@ -24,7 +28,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: token: ${{ secrets.PUBLISH_TOKEN }} fetch-depth: 0 # otherwise, you will fail to push refs to dest repo @@ -32,16 +36,16 @@ jobs: # Setting up helm binary - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 with: - version: v3.11.3 + version: v3.20.2 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version-file: 'go.mod' check-latest: true - cache: true + cache: false - name: Install Dependencies run: | @@ -80,13 +84,13 @@ jobs: run: echo "created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to Docker Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.STAKATER_DOCKERHUB_USERNAME }} password: ${{ secrets.STAKATER_DOCKERHUB_PASSWORD }} @@ -96,13 +100,12 @@ jobs: echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV - name: Build and Push Docker Image to Docker registry - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ env.DOCKER_FILE_PATH }} pull: true push: true - cache-to: type=inline platforms: linux/amd64,linux/arm,linux/arm64 tags: | ${{ env.DOCKER_IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.RELEASE_VERSION }} @@ -110,13 +113,14 @@ jobs: VERSION=${{ steps.generate_tag.outputs.RELEASE_VERSION }} COMMIT=${{ github.sha }} BUILD_DATE=${{ steps.prep.outputs.created }} + EDITION=${{ env.RELOADER_EDITION }} labels: | org.opencontainers.image.source=${{ github.event.repository.clone_url }} org.opencontainers.image.created=${{ steps.prep.outputs.created }} org.opencontainers.image.revision=${{ github.sha }} - name: Build and Push Docker UBI Image to Docker registry - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ env.DOCKER_UBI_FILE_PATH }} @@ -124,7 +128,6 @@ jobs: push: true build-args: | BUILDER_IMAGE=${{ env.DOCKER_IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.RELEASE_VERSION }} - cache-to: type=inline platforms: linux/amd64,linux/arm64 tags: | ${{ env.DOCKER_IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.RELEASE_VERSION }}-ubi @@ -134,7 +137,7 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} - name: Login to ghcr registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ${{env.REGISTRY}} username: stakater-user @@ -146,13 +149,12 @@ jobs: # tag this image as latest as it will be used in plain manifests - name: Build and Push Docker Image to ghcr registry - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ env.DOCKER_FILE_PATH }} pull: true push: true - cache-to: type=inline platforms: linux/amd64,linux/arm,linux/arm64 tags: | ${{ env.GHCR_IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.RELEASE_VERSION }},${{ env.GHCR_IMAGE_REPOSITORY }}:latest @@ -160,13 +162,14 @@ jobs: VERSION=${{ steps.generate_tag.outputs.RELEASE_VERSION }} COMMIT=${{ github.sha }} BUILD_DATE=${{ steps.prep.outputs.created }} + EDITION=${{ env.RELOADER_EDITION }} labels: | org.opencontainers.image.source=${{ github.event.repository.clone_url }} org.opencontainers.image.created=${{ steps.prep.outputs.created }} org.opencontainers.image.revision=${{ github.sha }} - name: Build and Push Docker UBI Image to ghcr registry - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: ${{ env.DOCKER_UBI_FILE_PATH }} @@ -174,7 +177,6 @@ jobs: push: true build-args: | BUILDER_IMAGE=${{ env.GHCR_IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.RELEASE_VERSION }} - cache-to: type=inline platforms: linux/amd64,linux/arm64 tags: | ${{ env.GHCR_IMAGE_REPOSITORY }}:${{ steps.generate_tag.outputs.RELEASE_VERSION }}-ubi @@ -183,27 +185,12 @@ jobs: org.opencontainers.image.created=${{ steps.prep.outputs.created }} org.opencontainers.image.revision=${{ github.sha }} - - name: Build and Push Docker Image for Docs to ghcr registry - uses: docker/build-push-action@v6 - with: - context: . - file: Dockerfile-docs - pull: true - push: true - cache-to: type=inline - tags: | - ${{ env.GHCR_IMAGE_REPOSITORY }}/docs:${{ steps.generate_tag.outputs.RELEASE_VERSION }} - labels: | - org.opencontainers.image.source=${{ github.event.repository.clone_url }} - org.opencontainers.image.created=${{ steps.prep.outputs.created }} - org.opencontainers.image.revision=${{ github.sha }} - ############################## ## Add steps to generate required artifacts for a release here(helm chart, operator manifest etc.) ############################## - name: Run GoReleaser - uses: goreleaser/goreleaser-action@master + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: latest args: release --clean @@ -211,7 +198,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }} - name: Notify Slack - uses: 8398a7/action-slack@v3 + uses: 8398a7/action-slack@77eaa4f1c608a7d68b38af4e3f739dcd8cba273e # v3 if: always() # Pick up events even if the job fails or is canceled. with: status: ${{ job.status }} diff --git a/.github/workflows/reloader-enterprise-published.yml b/.github/workflows/reloader-enterprise-published.yml index 9015c2c0..6d092154 100644 --- a/.github/workflows/reloader-enterprise-published.yml +++ b/.github/workflows/reloader-enterprise-published.yml @@ -4,14 +4,21 @@ on: release: types: [published] +# Authenticates with a PAT, not GITHUB_TOKEN — no token scopes needed. +permissions: {} + jobs: dispatch: runs-on: ubuntu-latest steps: - name: Trigger target repository workflow + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | + payload=$(jq -nc --arg tag "$RELEASE_TAG" \ + '{event_type: "release-published", client_payload: {tag: $tag}}') curl -X POST \ -H "Accept: application/vnd.github.v3+json" \ -H "Authorization: token ${{ secrets.STAKATER_AB_TOKEN_FOR_RLDR }}" \ https://api.github.com/repos/stakater-ab/reloader-enterprise/dispatches \ - -d '{"event_type":"release-published","client_payload":{"tag":"${{ github.event.release.tag_name }}"}}' + -d "$payload" diff --git a/.github/workflows/reloader-enterprise-unpublished.yml b/.github/workflows/reloader-enterprise-unpublished.yml index e1d6743f..99274789 100644 --- a/.github/workflows/reloader-enterprise-unpublished.yml +++ b/.github/workflows/reloader-enterprise-unpublished.yml @@ -4,14 +4,21 @@ on: release: types: [unpublished ] +# Authenticates with a PAT, not GITHUB_TOKEN — no token scopes needed. +permissions: {} + jobs: dispatch: runs-on: ubuntu-latest steps: - name: Trigger target repository workflow + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | + payload=$(jq -nc --arg tag "$RELEASE_TAG" \ + '{event_type: "release-unpublished", client_payload: {tag: $tag}}') curl -X POST \ -H "Accept: application/vnd.github.v3+json" \ -H "Authorization: token ${{ secrets.STAKATER_AB_TOKEN_FOR_RLDR }}" \ https://api.github.com/repos/stakater-ab/reloader-enterprise/dispatches \ - -d '{"event_type":"release-unpublished","client_payload":{"tag":"${{ github.event.release.tag_name }}"}}' + -d "$payload" diff --git a/.gitignore b/.gitignore index 5beaa628..407ee6b9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,15 @@ dist /reloader /Reloader !**/chart/reloader +!**/internal/reloader *.tgz styles/ site/ /mkdocs.yml yq bin +test/loadtest/results +test/loadtest/loadtest +# Temporary NFS files +.nfs* *.test diff --git a/.golangci.yml b/.golangci.yml index 8644bc04..31d14577 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,7 @@ version: "2" run: - go: "1.25" + go: "1.26" timeout: 5m allow-parallel-runners: true diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..815f9439 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,300 @@ +# Stakater Reloader Project Memory + +## Project Purpose + +Reloader is a Kubernetes operator that automatically triggers rolling restarts of workloads when the ConfigMaps or Secrets they reference are updated. Without it, Kubernetes does not restart pods when configuration changes — operators must do it manually or rely on GitOps pipelines. + +**What it watches**: ConfigMaps, Secrets, Namespaces, and (optionally) `SecretProviderClassPodStatus` (CSI-mounted secrets). + +**Workload types it can reload**: Deployment, StatefulSet, DaemonSet, CronJob, Job, Argo Rollout, and OpenShift DeploymentConfig. + +**How restarts are triggered**: Two strategies (selected via `--reload-strategy`): +1. **env-vars** (default) — injects an environment variable (`STAKATER_{NAME}_{TYPE}`) into every container with the SHA1 hash of the resource's data. A change in data changes the env var value, causing Kubernetes to restart pods. +2. **annotations** — writes the SHA1 hash into the pod template's annotations, which also forces a rollout. + +**The core problem it solves**: ConfigMaps and Secrets are decoupled from pod lifecycle in Kubernetes. Applications reading config at startup see stale data after a config update unless pods are restarted. Reloader closes that gap automatically and selectively. + +**Potential improvements observed**: +- **Duplicate reload suppression**: If a workload references both a ConfigMap and a Secret that are updated in the same controller reconcile cycle, it may get reloaded twice. Could be solved with a per-workload debounce map keyed by namespace/name/resourceVersion, flushed after a short TTL. +- **CronJob/Job reload is destructive**: Jobs are deleted and recreated on change, which loses run history. Could instead only annotate the CronJob template without spawning a new Job. +- **No per-resource reload rate limiting**: A rapid-fire ConfigMap update (e.g., from a CI pipeline) can trigger many restarts. A cooldown window per resource would help. +- **CSI integration gap**: CSI volumes are watched at the `SecretProviderClassPodStatus` level, but the link back to the workload is indirect and may miss edge cases. Needs a direct map from SecretProviderClass → workloads that mount it. + +--- + +## Repo Map + +| Path | Owns | Inspect when | +|---|---|---| +| `main.go` | Entry point, delegates to `app.Run()` | Never needs changes | +| `internal/pkg/app/` | `Run()` bootstrap, Cobra command wiring | Startup sequence changes | +| `internal/pkg/cmd/` | CLI flags parsing, `startReloader()`, controller/HA wiring | Adding new flags or startup behavior | +| `internal/pkg/controller/` | Informer/queue per resource type, event handlers (Add/Update/Delete) | Watching new resource types, queue tuning | +| `internal/pkg/handler/` | Per-event handlers (create, update, delete), `doRollingUpgrade()`, pause deployment | Core reload logic changes | +| `internal/pkg/callbacks/` | Workload-specific get/list/update/patch functions, `RollingUpgradeFuncs` struct | Adding new workload types | +| `internal/pkg/options/` | All CLI flag variables, defaults, `ArgoRolloutStrategy` type | Adding or renaming flags | +| `internal/pkg/constants/` | Constants: env var postfixes, annotation prefix, strategy names, HA lock name | Renaming global identifiers | +| `internal/pkg/metrics/` | Prometheus `Collectors` struct, all metric registration and recording helpers | Adding metrics | +| `internal/pkg/alerts/` | Slack/Teams/GChat/raw webhook alerting, env var config | Alert sink changes | +| `internal/pkg/util/` | SHA generation via `crypto/sha.go`, env var name conversion, namespace/label utilities | Utility/hash changes | +| `internal/pkg/crypto/` | `GenerateSHA(data)` — SHA1 hex digest | Hash algorithm changes | +| `internal/pkg/leadership/` | Leader election via Kubernetes Lease, HA stop/start of controllers | HA behavior changes | +| `internal/pkg/testutil/` | Fake Kubernetes objects for unit tests | Writing new tests | +| `pkg/common/` | `ReloadCheckResult`, `ReloaderOptions`, `ShouldReload()` logic, `Config` struct | Reload decision logic, annotation precedence | +| `pkg/kube/` | `Clients` struct (k8s + OpenShift + Argo + CSI), `GetKubernetesClient()`, `ResourceMap` | Client initialization, new CRD clients | +| `deployments/` | Helm chart (`deployments/kubernetes/chart/reloader/`), Kustomize manifests | Helm values, RBAC, deployment config | +| `docs/` | User-facing annotation documentation, architecture notes | Writing docs or confirming annotation behavior | +| `scripts/` | Shell scripts used by CI and Makefile | Build/release pipeline | +| `test/loadtest/` | Load test CLI (`cmd/loadtest`), 13 scenarios (S1–S13), Kind cluster setup | Performance testing, regression benchmarks | +| `.github/` | CI workflows: lint, test, Kind e2e, multi-arch Docker build, release | CI changes | + +--- + +## Core Runtime Flow + +**1. Entry** — `main.go:10` calls `app.Run()`. + +**2. CLI Init** — `internal/pkg/app/app.go` calls `cmd.NewReloaderCommand()` which registers all Cobra flags from `options/flags.go` and runs `startReloader()`. + +**3. Client Setup** — `pkg/kube/client.go`: builds `kube.Clients` with: +- `kubernetes.Interface` — standard k8s client +- `appsclient.Interface` — OpenShift client (auto-detected by probing `deploymentconfigs`) +- `argorollout.Interface` — if `--is-Argo-Rollouts=true` +- `csiclient.Interface` — if `--enable-csi-integration` + +**4. Controller Creation** — `startReloader()` iterates `kube.ResourceMap` (configmaps, secrets, namespaces, and optionally secretproviderclasspodstatuses) and calls `controller.NewController()` for each resource in each watched namespace. + +**5. Informer/Queue** — `controller.NewController()`: +- Creates a `cache.NewFilteredListWatchFromClient` with label/field selectors. +- Registers `Add`, `Update`, `Delete` event handlers. +- Creates a `workqueue.TypedRateLimitingQueue` for async processing. + +**6. Event Detection**: +- `Add` — enqueues only if `ReloadOnCreate` is enabled (skips during initial sync unless `SyncAfterRestart`). +- `Update` — compares SHA of old vs new object data; enqueues only on real changes. +- `Delete` — enqueues only if `ReloadOnDelete` is enabled. +- Namespace events update `selectedNamespacesCache` for namespace-selector filtering. + +**7. Handler Dispatch** — The queue worker calls `handler.Handle()` on the dequeued item. Three handler types: +- `ResourceCreatedHandler` (`create.go`) — fires `doRollingUpgrade` or sends webhook. +- `ResourceUpdatedHandler` (`update.go`) — fires `doRollingUpgrade` or sends webhook. +- `ResourceDeleteHandler` (`delete.go`) — calls `invokeDeleteStrategy` (removes env vars or clears annotation). + +**8. Workload Discovery** — `doRollingUpgrade()` (`upgrade.go:181`) calls `rollingUpgrade()` for each workload type. For each type, `ItemsFunc` lists all workloads in the namespace, then `pkg/common.ShouldReload()` checks annotations to decide which ones need reloading. + +**9. Reload Execution** — `invokeReloadStrategy()` either: +- **env-vars**: mutates container env vars; uses JSON patch if `SupportsPatch=true`, full update otherwise. +- **annotations**: writes SHA to pod template annotations; same patch/update split. + +**10. Post-reload** — optionally pauses the Deployment via `pause_deployment.go`, records Kubernetes Events via `recorder`, updates Prometheus metrics, sends alert webhooks. + +**HA Mode**: if `--enable-ha`, `internal/pkg/leadership/` runs Kubernetes Lease-based leader election. Only the leader runs controllers; losing leadership stops them and marks the pod unhealthy. + +**HTTP Server**: port `:9090` serves `/metrics` (Prometheus) and liveness/readiness probes. + +--- + +## Reload Behavior And Annotations + +All annotation names are configurable via CLI flags; the values below are defaults. + +### Trigger Annotations (on workloads) + +| Annotation | Value | Behavior | +|---|---|---| +| `reloader.stakater.com/auto` | `"true"` | Reload on change to **any** ConfigMap or Secret referenced by the workload (via envFrom, env valueFrom, or volumes) | +| `configmap.reloader.stakater.com/auto` | `"true"` | Reload on change to **any referenced ConfigMap** only | +| `secret.reloader.stakater.com/auto` | `"true"` | Reload on change to **any referenced Secret** only | +| `secretproviderclass.reloader.stakater.com/auto` | `"true"` | Reload on change to **any referenced SecretProviderClass** only | +| `configmap.reloader.stakater.com/reload` | `"cm1,cm2"` | Reload only when the **named ConfigMaps** change (regex supported) | +| `secret.reloader.stakater.com/reload` | `"sec1,sec2"` | Reload only when the **named Secrets** change (regex supported) | +| `secretproviderclass.reloader.stakater.com/reload` | `"spc1"` | Reload only when the **named SecretProviderClass** changes | +| `reloader.stakater.com/search` | `"true"` | Reload when any ConfigMap/Secret tagged with `reloader.stakater.com/match: "true"` changes | + +### Exclude Annotations (on workloads) + +| Annotation | Value | Behavior | +|---|---|---| +| `reloader.stakater.com/ignore` | `"true"` | Skip this workload entirely | +| `configmaps.exclude.reloader.stakater.com/reload` | `"cm1,cm2"` | Exclude these named ConfigMaps from triggering reload | +| `secrets.exclude.reloader.stakater.com/reload` | `"sec1,sec2"` | Exclude these named Secrets | +| `secretproviderclasses.exclude.reloader.stakater.com/reload` | `"spc1"` | Exclude these named SecretProviderClasses | + +### Behavior Annotations (on workloads) + +| Annotation | Value | Behavior | +|---|---|---| +| `reloader.stakater.com/rollout-strategy` | `"restart"` or `"rollout"` | For Argo Rollouts: `"restart"` uses restartAt, `"rollout"` (default) uses full rollout update | +| `deployment.reloader.stakater.com/pause-period` | Go duration e.g. `"30s"` | Pause Deployment for this duration after reload | +| `deployment.reloader.stakater.com/paused-at` | RFC3339 timestamp | Set by Reloader to track pause start time; do not set manually | + +### Search/Match Pattern + +The `reloader.stakater.com/search` annotation on a workload pairs with `reloader.stakater.com/match: "true"` on a ConfigMap or Secret. Any workload with `search: true` will reload when any `match: true` resource changes. + +### Global Flag Overrides + +- `--auto-reload-all` — reload all workloads on any ConfigMap/Secret change; annotation not required. +- `--resources-to-ignore=configMaps` or `=secrets` — skip one type entirely. +- `--ignored-workload-types=jobs,cronjobs` — skip Job and CronJob reload. +- `--namespaces-to-ignore` — comma-separated namespace names to skip. +- `--namespace-selector` — only watch namespaces with matching labels. +- `--resource-label-selector` — only watch ConfigMaps/Secrets with matching labels. + +### Precedence Rules + +1. `reloader.stakater.com/ignore: "true"` wins everything — workload is skipped. +2. Exclude annotations override include annotations for specific named resources. +3. Named annotations (`.../reload`) are checked before auto annotations. +4. `--auto-reload-all` is the lowest-priority fallback (only applies if no annotation matches). +5. Annotations are checked on both the workload and its pod template (pod template takes precedence in some paths — verify in `pkg/common/common.go:ShouldReload()`). + +--- + +## Workload Support + +| Workload | SupportsPatch | Update Mechanism | Key files | +|---|---|---|---| +| **Deployment** | Yes | JSON patch or full update | `callbacks/rolling_upgrade.go`, `handler/upgrade.go:38` | +| **StatefulSet** | Yes | JSON patch or full update | `callbacks/rolling_upgrade.go`, `handler/upgrade.go:109` | +| **DaemonSet** | Yes | JSON patch or full update | `callbacks/rolling_upgrade.go`, `handler/upgrade.go:91` | +| **CronJob** | No | Creates a new Job from CronJob spec (adds `cronjob.kubernetes.io/instantiate: manual`) | `callbacks.CreateJobFromCronjob`, `handler/upgrade.go:55` | +| **Job** | No | Deletes old Job, creates new one (strips ResourceVersion, UID, Status, controller labels) | `callbacks.ReCreateJobFromjob`, `handler/upgrade.go:73` | +| **Argo Rollout** | No | Full update via Argo Rollouts client | `callbacks.UpdateRollout`, `handler/upgrade.go:127`; requires `--is-Argo-Rollouts=true` | +| **DeploymentConfig** | Yes | OpenShift DeploymentConfigs API | `callbacks/rolling_upgrade.go`; auto-detected by probing `deploymentconfigs` | + +**Reload flow per workload**: `doRollingUpgrade()` → `rollingUpgrade()` per type → `ItemsFunc` lists workloads → `ShouldReload()` filters → `invokeReloadStrategy()` patches or updates → optional pause + metrics + alert. + +--- + +## CSI Support + +**Enabled by**: `--enable-csi-integration` + +**What is watched**: `SecretProviderClassPodStatus` resources (from `sigs.k8s.io/secrets-store-csi-driver`). Resource name constant: `constants.SecretProviderClassController = "secretproviderclasspodstatuses"`. + +**How it works**: +1. The CSI driver injects secrets into pods as volume mounts and tracks injection state via `SecretProviderClassPodStatus` objects. +2. Reloader watches these objects for version changes. +3. When a version change is detected, it computes a SHA of the object's IDs and versions. +4. It then looks up the referenced `SecretProviderClass` and treats the event like a Secret update, triggering workload reloads. + +**Workload annotation**: `secretproviderclass.reloader.stakater.com/reload: "my-spc"` or `secretproviderclass.reloader.stakater.com/auto: "true"`. + +**Required**: CSI CRDs must be installed in the cluster. Reloader auto-detects their presence at startup. + +**Env var postfix**: `STAKATER_{NAME}_SECRETPROVIDERCLASS`. + +**Known limitations**: +- Only works for secrets mounted as volumes via CSI, not env-var-based CSI injection. +- The link from `SecretProviderClassPodStatus` → workload is indirect; edge cases may be missed. +- Requires the CSI driver CRDs to be pre-installed; Reloader won't start CSI controller if CRDs are absent. + +--- + +## Build, Test, And Run Commands + +**Go version**: `go 1.26.2` (from `go.mod`) + +| Purpose | Command | +|---|---| +| Run locally | `go run ./main.go` | +| Build binary | `make build` → `go build -o Reloader` | +| Unit tests | `make test` → `go test -timeout 1800s -v ./...` | +| Lint | `make lint` → `golangci-lint run ./...` (v2.6.1) | +| Docker build (single arch) | `make build-image ARCH=amd64` | +| Docker push | `make push` | +| Full release (build+push+manifest) | `make release ARCH=amd64` | +| Multi-arch release | `make release-all` | +| Generate k8s manifests | `make k8s-manifests` (Kustomize v5.3.0) | +| Load test (quick) | `make loadtest-quick LOADTEST_OLD_IMAGE=... LOADTEST_NEW_IMAGE=...` (runs S1, S4, S6) | +| Load test (full) | `make loadtest-full LOADTEST_OLD_IMAGE=... LOADTEST_NEW_IMAGE=...` | +| Load test (custom) | `make loadtest LOADTEST_SCENARIOS=S1,S3 LOADTEST_DURATION=120` | + +**Docker image**: `ghcr.io/stakater/reloader` — multi-arch (amd64, arm64, arm), distroless nonroot base. + +**Helm chart**: `deployments/kubernetes/chart/reloader/` — install via Helm or `kubectl apply -f deployments/kubernetes/reloader.yaml`. + +--- + +## Coding Conventions + +**Package boundaries**: Each `internal/pkg/` package has a single clear responsibility. Cross-package access goes through exported types/functions only. + +**Error handling**: `logrus.Errorf(...)` for non-fatal, `logrus.Fatalf(...)` for startup failures. Errors are returned up the call stack and logged at the point of action, not at every layer. Retry uses `k8s.io/client-go/util/retry.RetryOnConflict`. + +**Logging**: `logrus` with structured fields. Format controlled by `--log-format=json` flag. Log level controlled by `--log-level`. Messages follow the pattern: `"Changes detected in '%s' of type '%s' in namespace '%s'"`. + +**Kubernetes client patterns**: All k8s operations go through the `kube.Clients` struct. Use `context.TODO()` for context (no request-scoped contexts). List/watch via informers, not polling. + +**Callback pattern**: Workload-specific logic is encapsulated in `callbacks.RollingUpgradeFuncs` structs returned by `handler.Get*RollingUpgradeFuncs()`. Adding a new workload type = add a new `RollingUpgradeFuncs` factory function and call it in `doRollingUpgrade()`. + +**Test style**: Standard `testing.T`, `testify/assert`. Fake k8s objects via `testutil/kube.go`. Tests live alongside source in the same package. Large integration-style tests in `handler/upgrade_test.go`. + +**Naming patterns**: +- Annotation variables: `XxxUpdateOnChangeAnnotation`, `XxxReloaderAutoAnnotation` +- Callback funcs: `GetXxxItem`, `GetXxxItems`, `UpdateXxx`, `PatchXxx` +- Handler factories: `GetXxxRollingUpgradeFuncs()` + +**Adding new behavior**: Add flag to `options/flags.go` + `common.ReloaderOptions` struct → wire in `cmd/reloader.go` → implement logic in `handler/` or `callbacks/` → add metrics recording → write tests in `*_test.go`. + +--- + +## Gotchas And Risks + +**Duplicate reloads**: If a workload references multiple ConfigMaps/Secrets and all change simultaneously, each change event fires a separate reload. No deduplication exists within a reconcile window. This can cause unnecessary rolling restarts. + +**Controller init guard**: `secretControllerInitialized` and `configmapControllerInitialized` booleans in `controller/controller.go` prevent processing Add events during the initial list/sync (to avoid reloading everything on startup). If `--sync-after-restart` is set, both are pre-set to `true`, bypassing the guard. Be careful when this interacts with `--reload-on-create`. + +**Namespace filtering**: `--namespaces-to-ignore` does a name match; `--namespace-selector` watches namespaces by label and caches them in `selectedNamespacesCache`. The cache is updated on Namespace Add/Update/Delete events. A race between cache population and first ConfigMap event could cause missed reloads on startup in label-selected deployments. + +**RBAC**: Reloader requires get/list/watch on secrets and configmaps, and get/list/watch/update/patch on all workload types it manages. Missing RBAC silently causes no reloads (not an error — just empty lists). Check ClusterRole in `deployments/kubernetes/chart/reloader/templates/`. + +**GitOps drift**: If a GitOps tool (Flux, ArgoCD) manages the same Deployments, annotation or env var changes made by Reloader will be detected as drift and reverted. Use `--reload-strategy=annotations` with care in GitOps setups; `env-vars` strategy is generally safer since it modifies the pod template rather than workload-level annotations. + +**Annotation precedence edge case**: Annotations are checked first on the workload object, then on the pod template. If both are set to conflicting values, the behavior depends on which path `ShouldReload()` hits first. Verify in `pkg/common/common.go`. + +**CronJob/Job destructive reload**: Job recreation deletes the old Job. Any in-flight pod from that Job will be terminated. This is intentional but surprising. There is no protection for long-running jobs. + +**OpenShift DeploymentConfig**: Auto-detected by probing for the `deploymentconfigs` resource. If the probe fails at startup, OpenShift support is silently disabled. Check `pkg/kube/client.go`. + +**Argo Rollouts**: Must be explicitly enabled via `--is-Argo-Rollouts=true`. Without it, Rollout objects are never listed. The `SupportsPatch=false` means full object updates are used — be aware of potential conflicts with Argo's own controller. + +**CSI rotation behavior**: `SecretProviderClassPodStatus` is updated by the CSI driver when secrets rotate. Reloader reacts to those updates. However, if the CSI driver updates the status in a way that doesn't change the versions Reloader tracks, the reload will be missed. + +**Backward compatibility**: Annotation names are configurable, so changing defaults would break existing clusters. Never change default annotation values without a migration path. + +**Tests to update for risky changes**: `handler/upgrade_test.go` (large suite covering all workload types), `controller/controller_test.go` (event handling), `pkg/common/common_test.go` (reload decision logic). + +--- + +## Open Questions + +- **Exact `ShouldReload()` precedence**: The code in `pkg/common/common.go` checks annotations in a specific order. The exact tie-breaking when both workload-level and pod-template-level annotations are set should be verified by reading that function fully before making annotation behavior changes. +- **CSI → workload mapping**: How exactly does Reloader map a `SecretProviderClassPodStatus` change back to workloads? Is it via the SecretProviderClass name matching an annotation on the workload, or via volume reference scanning? Needs confirmation before adding CSI-related features. +- **`ContainerPatchPathFunc` field**: `RollingUpgradeFuncs` has a `ContainerPatchPathFunc` field, but it is not documented — unclear if/how it differs from `ContainersFunc` in patch scenarios. +- **Webhook vs alert**: `--webhook-url` replaces reloading with a POST request. `ALERT_WEBHOOK_URL` env var sends an alert *after* reloading. These are two different mechanisms; the naming is confusing and easy to conflate. +- **Load test scenarios S7–S13**: Only S1, S4, and S6 are confirmed from CI. The behavior and coverage of the remaining scenarios is unknown without reading `test/loadtest/` in full. +- **`SyncAfterRestart` semantics**: Flag docs say it "syncs add events after restart" but only if `ReloadOnCreate` is also true. The interaction between these two flags in HA mode (where controllers restart on leader change) needs verification. + +--- + +## Important Files + +| File | Description | +|---|---| +| `internal/pkg/cmd/reloader.go` | `startReloader()` — main wiring of clients, controllers, HA, and HTTP server | +| `internal/pkg/handler/upgrade.go` | `doRollingUpgrade()` + all `Get*RollingUpgradeFuncs()` factories | +| `internal/pkg/callbacks/rolling_upgrade.go` | All workload-specific get/update/patch implementations | +| `pkg/common/common.go` | `ShouldReload()` — the annotation decision tree | +| `internal/pkg/options/flags.go` | Every configurable option with defaults | +| `internal/pkg/controller/controller.go` | Informer setup, queue, event handlers | +| `pkg/kube/client.go` | Multi-client initialization and OpenShift/CSI detection | +| `internal/pkg/handler/pause_deployment.go` | Pause/resume deployment logic with timers | +| `internal/pkg/leadership/leadership.go` | HA leader election | +| `internal/pkg/metrics/prometheus.go` | All Prometheus collector definitions | +| `internal/pkg/alerts/alert.go` | Slack/Teams/GChat alerting | +| `internal/pkg/constants/constants.go` | Global constants (env var prefixes, annotation prefix, strategy names) | +| `deployments/kubernetes/chart/reloader/values.yaml` | Helm chart defaults — source of truth for production config | +| `handler/upgrade_test.go` | Largest test suite; must be updated for any reload logic change | +| `Makefile` | All build/test/release/loadtest commands | diff --git a/Dockerfile b/Dockerfile index 0391463c..d9ca0edc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ ARG BUILDER_IMAGE ARG BASE_IMAGE # Build the manager binary -FROM --platform=${BUILDPLATFORM} ${BUILDER_IMAGE:-golang:1.25.5} AS builder +FROM --platform=${BUILDPLATFORM} ${BUILDER_IMAGE:-golang:1.26} AS builder ARG TARGETOS ARG TARGETARCH @@ -12,6 +12,7 @@ ARG GOPRIVATE ARG COMMIT ARG VERSION ARG BUILD_DATE +ARG EDITION=oss WORKDIR /workspace @@ -33,10 +34,10 @@ RUN CGO_ENABLED=0 \ GOPROXY=${GOPROXY} \ GOPRIVATE=${GOPRIVATE} \ GO111MODULE=on \ - go build -ldflags="-s -w \ - -X github.com/stakater/Reloader/internal/pkg/metadata.Version=${VERSION} \ - -X github.com/stakater/Reloader/internal/pkg/metadata.Commit=${COMMIT} \ - -X github.com/stakater/Reloader/internal/pkg/metadata.BuildDate=${BUILD_DATE}" \ + go build -ldflags="-s -w -X github.com/stakater/Reloader/pkg/common.Version=${VERSION} \ + -X github.com/stakater/Reloader/pkg/common.Commit=${COMMIT} \ + -X github.com/stakater/Reloader/pkg/common.BuildDate=${BUILD_DATE} \ + -X github.com/stakater/Reloader/pkg/common.Edition=${EDITION}" \ -installsuffix 'static' -mod=mod -a -o manager ./cmd/reloader # Use distroless as minimal base image to package the manager binary diff --git a/Dockerfile-docs b/Dockerfile-docs deleted file mode 100644 index feb745c1..00000000 --- a/Dockerfile-docs +++ /dev/null @@ -1,35 +0,0 @@ -FROM python:3.14-alpine as builder - -# set workdir -RUN mkdir -p $HOME/application -WORKDIR $HOME/application - -# copy the entire application -COPY --chown=1001:root . . - -RUN pip3 install -r theme_common/requirements.txt - -# Combine Theme Resources -RUN python theme_common/scripts/combine_theme_resources.py -s theme_common/resources -ov theme_override/resources -o dist/_theme -# Produce mkdocs file -RUN python theme_common/scripts/combine_mkdocs_config_yaml.py theme_common/mkdocs.yml theme_override/mkdocs.yml mkdocs.yml - -# build the docs -RUN mkdocs build - -FROM nginxinc/nginx-unprivileged:1.29-alpine as deploy -COPY --from=builder $HOME/application/site/ /usr/share/nginx/html/reloader/ -COPY docs-nginx.conf /etc/nginx/conf.d/default.conf - -# set non-root user -USER 1001 - -LABEL name="Stakater Reloader Documentation" \ - maintainer="Stakater " \ - vendor="Stakater" \ - release="1" \ - summary="Documentation for Stakater Reloader" - -EXPOSE 8080:8080/tcp - -CMD ["nginx", "-g", "daemon off;"] diff --git a/Dockerfile.ubi b/Dockerfile.ubi index b33a7990..76ce5d4a 100644 --- a/Dockerfile.ubi +++ b/Dockerfile.ubi @@ -4,7 +4,7 @@ ARG BASE_IMAGE # First stage: Build the binary (using the standard Dockerfile as builder) FROM --platform=${BUILDPLATFORM} ${BUILDER_IMAGE} AS SRC -FROM ${BASE_IMAGE:-registry.access.redhat.com/ubi9/ubi:latest} AS ubi +FROM ${BASE_IMAGE:-registry.access.redhat.com/ubi9/ubi:9.8-1779374378} AS ubi ARG TARGETARCH @@ -21,7 +21,21 @@ RUN mkdir /image && \ COPY ubi-build-files-${TARGETARCH}.txt /tmp # Copy all the required files from the base UBI image into the image directory # As the go binary is not statically compiled this includes everything needed for CGO to work, cacerts, tzdata and RH release files -RUN tar cf /tmp/files.tar -T /tmp/ubi-build-files-${TARGETARCH}.txt && tar xf /tmp/files.tar -C /image/ +# Filter existing files and exclude temporary entitlement files that may be removed during build +RUN while IFS= read -r file; do \ + [ -z "$file" ] && continue; \ + if [ -e "$file" ] || [ -L "$file" ]; then \ + echo "$file"; \ + fi; \ + done < /tmp/ubi-build-files-${TARGETARCH}.txt > /tmp/existing-files.txt && \ + if [ -s /tmp/existing-files.txt ]; then \ + tar -chf /tmp/files.tar --exclude='etc/pki/entitlement-host*' -T /tmp/existing-files.txt 2>&1 | grep -vE "(File removed before we read it|Cannot stat)" || true; \ + if [ -f /tmp/files.tar ]; then \ + tar xf /tmp/files.tar -C /image/ 2>/dev/null || true; \ + rm -f /tmp/files.tar; \ + fi; \ + fi && \ + rm -f /tmp/existing-files.txt # Generate a rpm database which contains all the packages that you said were needed in ubi-build-files-*.txt RUN rpm --root /image --initdb \ diff --git a/Makefile b/Makefile index be013e41..df1eb792 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,9 @@ DOCKER_IMAGE ?= ghcr.io/stakater/reloader # Default value "dev" VERSION ?= 0.0.1 +# Full image reference (used for docker-build) +IMG ?= $(DOCKER_IMAGE):v$(VERSION) + REPOSITORY_GENERIC = ${DOCKER_IMAGE}:${VERSION} REPOSITORY_ARCH = ${DOCKER_IMAGE}:v${VERSION}-${ARCH} BUILD= @@ -38,9 +41,16 @@ $(LOCALBIN): ## Tool Binaries KUBECTL ?= kubectl +KUSTOMIZE ?= $(LOCALBIN)/kustomize-$(KUSTOMIZE_VERSION) +CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen-$(CONTROLLER_TOOLS_VERSION) +ENVTEST ?= $(LOCALBIN)/setup-envtest-$(ENVTEST_VERSION) YQ ?= $(LOCALBIN)/yq ## Tool Versions +KUSTOMIZE_VERSION ?= v5.3.0 +CONTROLLER_TOOLS_VERSION ?= v0.14.0 +ENVTEST_VERSION ?= release-0.17 + YQ_VERSION ?= v4.27.5 YQ_DOWNLOAD_URL = "https://github.com/mikefarah/yq/releases/download/$(YQ_VERSION)/yq_$(OS)_$(ARCH)" @@ -55,6 +65,36 @@ $(YQ): @chmod +x $(YQ) @echo "yq downloaded successfully to $(YQ)." +.PHONY: kustomize +kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. +$(KUSTOMIZE): $(LOCALBIN) + $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) + +.PHONY: controller-gen +controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. +$(CONTROLLER_GEN): $(LOCALBIN) + $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) + +.PHONY: envtest +envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. +$(ENVTEST): $(LOCALBIN) + $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) + + +# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist +# $1 - target path with name of binary (ideally with version) +# $2 - package url which can be installed +# $3 - specific version of package +define go-install-tool +@[ -f $(1) ] || { \ +set -e; \ +package=$(2)@$(3) ;\ +echo "Downloading $${package}" ;\ +GOBIN=$(LOCALBIN) go install $${package} ;\ +mv "$$(echo "$(1)" | sed "s/-$(3)$$//")" $(1) ;\ +} +endef + default: build test install: @@ -69,6 +109,10 @@ build: lint: ## Run golangci-lint on the codebase go tool golangci-lint run ./... +fmt: ## Format all Go files + go tool goimports -w -local github.com/stakater/Reloader . + gofmt -w . + build-image: docker buildx build \ --platform ${OS}/${ARCH} \ @@ -104,7 +148,50 @@ manifest: docker manifest annotate --arch $(ARCH) $(REPOSITORY_GENERIC) $(REPOSITORY_ARCH) test: - "$(GOCMD)" test -timeout 1800s -v -short ./cmd/... ./internal/... + "$(GOCMD)" test -timeout 1800s -v -count=1 ./internal/... ./pkg/... ./test/e2e/utils/... + +##@ E2E Tests + +E2E_IMG ?= ghcr.io/stakater/reloader:test +E2E_TIMEOUT ?= 45m +KIND_CLUSTER ?= reloader-e2e +CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null) +# Set SKIP_BUILD=true to skip the image build/load steps and use a pre-built image. +SKIP_BUILD ?= false +# Number of parallel Ginkgo workers. Defaults to 1 (sequential). Override with GINKGO_PROCS=N. +GINKGO_PROCS ?= 1 + +.PHONY: e2e-setup +e2e-setup: ## One-time setup: create Kind cluster and install dependencies (Argo, CSI, Vault) + @if kind get clusters 2>/dev/null | grep -q "^$(KIND_CLUSTER)$$"; then \ + echo "Kind cluster $(KIND_CLUSTER) already exists"; \ + else \ + echo "Creating Kind cluster $(KIND_CLUSTER)..."; \ + kind create cluster --name $(KIND_CLUSTER); \ + fi + ./scripts/e2e-cluster-setup.sh + +.PHONY: e2e +e2e: ## Run e2e tests (build/load image unless SKIP_BUILD=true, then run tests in parallel) +ifneq ($(SKIP_BUILD),true) + $(CONTAINER_RUNTIME) build -t $(E2E_IMG) -f Dockerfile . +ifeq ($(notdir $(CONTAINER_RUNTIME)),podman) + $(CONTAINER_RUNTIME) save $(E2E_IMG) -o /tmp/reloader-e2e.tar + kind load image-archive /tmp/reloader-e2e.tar --name $(KIND_CLUSTER) + rm -f /tmp/reloader-e2e.tar +else + kind load docker-image $(E2E_IMG) --name $(KIND_CLUSTER) +endif +endif + RELOADER_IMAGE=$(E2E_IMG) "$(GOCMD)" tool ginkgo --keep-going -v --procs=$(GINKGO_PROCS) --timeout=$(E2E_TIMEOUT) ./test/e2e/... + +.PHONY: e2e-cleanup +e2e-cleanup: ## Cleanup: remove test resources and delete Kind cluster + ./scripts/e2e-cluster-cleanup.sh + kind delete cluster --name $(KIND_CLUSTER) + +.PHONY: e2e-ci +e2e-ci: e2e-setup e2e e2e-cleanup ## CI pipeline: setup, run tests, cleanup .PHONY: docker-build docker-build: ## Build Docker image @@ -137,3 +224,43 @@ yq-install: @curl -sL $(YQ_DOWNLOAD_URL) -o $(YQ_BIN) @chmod +x $(YQ_BIN) @echo "yq $(YQ_VERSION) installed at $(YQ_BIN)" + +# ============================================================================= +# Load Testing +# ============================================================================= + +LOADTEST_BIN = test/loadtest/loadtest +LOADTEST_OLD_IMAGE ?= localhost/reloader:old +LOADTEST_NEW_IMAGE ?= localhost/reloader:new +LOADTEST_DURATION ?= 60 +LOADTEST_SCENARIOS ?= all + +.PHONY: loadtest-build loadtest-quick loadtest-full loadtest loadtest-clean + +loadtest-build: ## Build loadtest binary + cd test/loadtest && $(GOCMD) build -o loadtest ./cmd/loadtest + +loadtest-quick: loadtest-build ## Run quick load tests (S1, S4, S6) + cd test/loadtest && ./loadtest run \ + --old-image=$(LOADTEST_OLD_IMAGE) \ + --new-image=$(LOADTEST_NEW_IMAGE) \ + --scenario=S1,S4,S6 \ + --duration=$(LOADTEST_DURATION) + +loadtest-full: loadtest-build ## Run full load test suite + cd test/loadtest && ./loadtest run \ + --old-image=$(LOADTEST_OLD_IMAGE) \ + --new-image=$(LOADTEST_NEW_IMAGE) \ + --scenario=all \ + --duration=$(LOADTEST_DURATION) + +loadtest: loadtest-build ## Run load tests with configurable scenarios (default: all) + cd test/loadtest && ./loadtest run \ + --old-image=$(LOADTEST_OLD_IMAGE) \ + --new-image=$(LOADTEST_NEW_IMAGE) \ + --scenario=$(LOADTEST_SCENARIOS) \ + --duration=$(LOADTEST_DURATION) + +loadtest-clean: ## Clean loadtest binary and results + rm -f $(LOADTEST_BIN) + rm -rf test/loadtest/results diff --git a/README.md b/README.md index ae0a00ac..7af2c133 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,19 @@ [![Release](https://img.shields.io/github/release/stakater/reloader.svg?style=flat-square)](https://github.com/stakater/reloader/releases/latest) [![GitHub tag](https://img.shields.io/github/tag/stakater/reloader.svg?style=flat-square)](https://github.com/stakater/reloader/releases/latest) [![Docker Pulls](https://img.shields.io/docker/pulls/stakater/reloader.svg?style=flat-square)](https://hub.docker.com/r/stakater/reloader/) -[![Docker Stars](https://img.shields.io/docker/stars/stakater/reloader.svg?style=flat-square)](https://hub.docker.com/r/stakater/reloader/) +[![GitHub Stars](https://img.shields.io/github/stars/stakater/Reloader.svg?style=flat-square)](https://github.com/stakater/Reloader) [![license](https://img.shields.io/github/license/stakater/reloader.svg?style=flat-square)](LICENSE) ## 🔁 What is Reloader? -Reloader is a Kubernetes controller that automatically triggers rollouts of workloads (like Deployments, StatefulSets, and more) whenever referenced `Secrets` or `ConfigMaps` are updated. +Reloader is a Kubernetes controller that automatically triggers rollouts of workloads (like Deployments, StatefulSets, and more) whenever referenced `Secrets`, `ConfigMaps` or **optionally CSI-mounted secrets** are updated. In a traditional Kubernetes setup, updating a `Secret` or `ConfigMap` does not automatically restart or redeploy your workloads. This can lead to stale configurations running in production, especially when dealing with dynamic values like credentials, feature flags, or environment configs. Reloader bridges that gap by ensuring your workloads stay in sync with configuration changes — automatically and safely. +📚 Full documentation is available at [Stakater documentation site](https://docs.stakater.com/reloader/) + ## 🚀 Why Reloader? - ✅ **Zero manual restarts**: No need to manually rollout workloads after config/secret changes. @@ -50,6 +52,21 @@ flowchart LR - `Secrets` and `ConfigMaps` are watched by Reloader. - When changes are detected, Reloader automatically triggers a rollout of the associated workloads, ensuring your app always runs with the latest configuration. +## 🏢 Reloader Enterprise + +Reloader OSS is free and production-proven with 24B+ downloads. + +For teams with stricter requirements: + +| Need | Enterprise | +|------|-----------| +| CVE-free, signed images with SBOM | ✅ | +| SLA-backed support from Kubernetes experts | ✅ | +| Artifact provenance for compliance audits | ✅ | +| Dedicated escalation path | ✅ | + +→ [Contact Sales](mailto:sales@stakater.com) for info about Reloader Enterprise. + ## ⚡ Quick Start ### 1. Install Reloader @@ -85,16 +102,6 @@ spec: This tells Reloader to watch the `ConfigMap` and `Secret` referenced in this deployment. When either is updated, it will trigger a rollout. -## 🏢 Enterprise Version - -Stakater offers an enterprise-grade version of Reloader with: - -1. SLA-backed support -1. Certified images -1. Private Slack support - -Contact [`sales@stakater.com`](mailto:sales@stakater.com) for info about Reloader Enterprise. - ## 🧩 Usage Reloader supports multiple annotation-based controls to let you **customize when and how your Kubernetes workloads are reloaded** upon changes in `Secrets` or `ConfigMaps`. @@ -169,9 +176,11 @@ metadata: This instructs Reloader to skip all reload logic for that resource across all workloads. -### 4. ⚙️ Workload-Specific Rollout Strategy +### 4. ⚙️ Workload-Specific Rollout Strategy (Argo Rollouts Only) -By default, Reloader uses the **rollout** strategy — it updates the pod template to trigger a new rollout. This works well in most cases, but it can cause problems if you're using GitOps tools like ArgoCD, which detect this as configuration drift. +Note: This is only applicable when using [Argo Rollouts](https://argoproj.github.io/argo-rollouts/). It is ignored for standard Kubernetes `Deployments`, `StatefulSets`, or `DaemonSets`. To use this feature, Argo Rollouts support must be enabled in Reloader (for example via --is-argo-rollouts=true). + +By default, Reloader triggers the Argo Rollout controller to perform a standard rollout by updating the pod template. This works well in most cases, however, because this modifies the workload spec, GitOps tools like ArgoCD will detect this as "Configuration Drift" and mark your application as OutOfSync. To avoid that, you can switch to the **restart** strategy, which simply restarts the pod without changing the pod template. @@ -192,6 +201,8 @@ metadata: 1. You want a quick restart without changing the workload spec 1. Your platform restricts metadata changes +This setting affects Argo Rollouts behavior, not Argo CD sync settings. + ### 5. ❗ Annotation Behavior Rules & Compatibility - `reloader.stakater.com/auto` and `reloader.stakater.com/search` **cannot be used together** — the `auto` annotation takes precedence. @@ -239,6 +250,61 @@ This feature allows you to pause rollouts for a deployment for a specified durat 1. ✅ Your deployment references multiple ConfigMaps or Secrets that may be updated at the same time. 1. ✅ You want to minimize unnecessary rollouts and reduce downtime caused by back-to-back configuration changes. +### 8. 🔐 CSI Secret Provider Support + +Reloader supports the [Secrets Store CSI Driver](https://secrets-store-csi-driver.sigs.k8s.io/), which allows mounting secrets from external secret stores (like AWS Secrets Manager, Azure Key Vault, HashiCorp Vault) directly into pods. +Unlike Kubernetes Secret objects, CSI-mounted secrets do not always trigger native Kubernetes update events. Reloader solves this by watching CSI status resources and restarting affected workloads when mounted secret versions change. + +#### How it works + +When secret rotation is enabled, the Secrets Store CSI Driver updates a Kubernetes resource called: `SecretProviderClassPodStatus` + +This resource reflects the currently mounted secret versions for a pod. +Reloader watches these updates and triggers a rollout when a change is detected. + +#### Prerequisites + +- Secrets Store CSI Driver must be installed in your cluster +- Secret rotation enabled in the CSI driver. +- Enable CSI integration in Reloader: `--enable-csi-integration=true` + +#### Annotations for CSI-mounted Secrets + +| Annotation | Description | +|------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| `reloader.stakater.com/auto: "true"` | Global Discovery: Automatically discovers and reloads the workload when any mounted ConfigMap or Secret is updated. | +| `secretproviderclass.reloader.stakater.com/auto: 'true'` | CSI Discovery: Specifically watches for updates to all SecretProviderClasses used by the workload (CSI driver integration). | +| `secretproviderclass.reloader.stakater.com/reload: "my-secretproviderclass"` | Targeted Reload: Only reloads the workload when the specifically named SecretProviderClass(es) are updated. | + +Reloader monitors changes at the **per-secret level** by watching the `SecretProviderClassPodStatus`. Make sure each secret you want to monitor is properly defined with a `secretKey` in your `SecretProviderClass`: + +```yaml +apiVersion: secrets-store.csi.x-k8s.io/v1 +kind: SecretProviderClass +metadata: + name: vault-reloader-demo + namespace: test +spec: + provider: vault + parameters: + vaultAddress: "http://vault.vault.svc:8200" + vaultSkipTLSVerify: "true" + roleName: "demo-role" + objects: | + - objectName: "password" + secretPath: "secret/data/reloader-demo" + secretKey: "password" +``` + +***Important***: Reloader tracks changes to individual secrets (identified by `secretKey`). If your SecretProviderClass doesn't specify `secretKey` for each object, Reloader may not detect updates correctly. + +#### Notes & Limitations + +Reloader reacts to CSI status changes, not direct updates to external secret stores +Secret rotation must be enabled in the CSI driver for updates to be detected +CSI limitations (such as `subPath` mounts) still apply and may require pod restarts +If secrets are synced to Kubernetes Secret objects, standard Reloader behavior applies and CSI support may not be required + ## 🚀 Installation ### 1. 📦 Helm @@ -374,6 +440,7 @@ These flags allow you to redefine annotation keys used in your workloads or reso | `--search-match-annotation` | Overrides `reloader.stakater.com/match` | | `--secret-annotation` | Overrides `secret.reloader.stakater.com/reload` | | `--configmap-annotation` | Overrides `configmap.reloader.stakater.com/reload` | +| `--ignore-annotation` | Overrides `reloader.stakater.com/ignore` | | `--pause-deployment-annotation` | Overrides `deployment.reloader.stakater.com/pause-period` | | `--pause-deployment-time-annotation` | Overrides `deployment.reloader.stakater.com/paused-at` | @@ -388,12 +455,19 @@ These flags allow you to redefine annotation keys used in your workloads or reso Reloader is compatible with Kubernetes >= 1.19 +## 🏢 Adopters + +Reloader has **24B+ Docker pulls** across thousands of Kubernetes clusters worldwide. + +If you're running Reloader in production, we'd love to hear from you: + +- 💬 **Share your story** → [Show & Tell Discussion](https://github.com/stakater/Reloader/discussions/1137) +- 🏷️ **Add your logo** → [ADOPTERS.md](./adopters/ADOPTERS.md) + +[See who's using Reloader →](./adopters/ADOPTERS.md) + ## Help -### Documentation - -The Reloader documentation can be viewed from [the doc site](https://docs.stakater.com/reloader/). The doc source is in the [docs](./docs/) folder. - ### Have a question? File a GitHub [issue](https://github.com/stakater/Reloader/issues). @@ -430,7 +504,7 @@ PRs are welcome. In general, we follow the "fork-and-pull" Git workflow: ## Release Processes -_Repository GitHub releases_: As requested by the community in [issue 685](https://github.com/stakater/Reloader/issues/685), Reloader is now based on a manual release process. Releases are no longer done on every merged PR to the main branch, but manually on request. +*Repository GitHub releases*: As requested by the community in [issue 685](https://github.com/stakater/Reloader/issues/685), Reloader is now based on a manual release process. Releases are no longer done on every merged PR to the main branch, but manually on request. To make a GitHub release: @@ -443,7 +517,7 @@ To make a GitHub release: 1. Code owners create another branch from `master` and bump the helm chart version as well as Reloader image version. - Code owners create a PR with `release/helm-chart` label, example: [PR-846](https://github.com/stakater/Reloader/pull/846) -_Repository git tagging_: Push to the main branch will create a merge-image and merge-tag named `merge-${{ github.event.number }}`, for example `merge-800` when pull request number 800 is merged. +*Repository git tagging*: Push to the main branch will create a merge-image and merge-tag named `merge-${{ github.event.number }}`, for example `merge-800` when pull request number 800 is merged. ## Changelog diff --git a/VERSION b/VERSION index f86e0298..323afbcd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.12 +1.4.14 diff --git a/adopters/ADOPTERS.md b/adopters/ADOPTERS.md new file mode 100644 index 00000000..f308d5aa --- /dev/null +++ b/adopters/ADOPTERS.md @@ -0,0 +1,74 @@ +# Adopters + +Organizations and teams running Reloader in production. + +This list exists to help the community understand real-world usage patterns and +to give visibility to the teams that have made Reloader part of their infrastructure. +It also helps us prioritize what to build next. + +**Want to be listed?** +Open a PR — add your logo to [`/adopters/logos/`](./logos/) and a row to the +table below. See the [contribution guide](#how-to-add-your-organization) at the +bottom of this page. + +--- + +## Organizations Using Reloader + + + + + + + + +
Stakater CloudExelient AB
+ + +--- + +## Adopter Details + +| Organization | Quote | Use Case | Scale | Since | +|---|---|---|---|---| +| [Stakater Cloud](www.stakater.cloud) | "Reloader is foundational to Stakater Cloud — every secret rotation, config change, and cert renewal, handled automatically." | Secret rotation, Cert Renewal, Config Propagation | 4 regions, 800+ namespaces | 2024 | +| [Exelient AB](www.exelient.se) | "The cert-manager + Reloader combo is gold. Renewed certs, live and hassle-free." | Secret rotation, Cert Renewal, Config Propagation | 1 cluster, 3 namespaces | 2026 | + +--- + +## How to Add Your Organization + +Adding your organization takes about 5 minutes and means a lot to the project. + +### Option A — Pull Request (gets you a logo in the grid) + +1. Fork the repository +2. Add your logo to [`/adopters/logos/`](./logos/) + - SVG preferred, PNG accepted + - Name the file after your company: `acme-corp.svg` + - Keep it under 100KB +3. Add a row to the **Adopter Details** table above +4. Open a PR with the commit title: `docs: add to ADOPTERS.md` + +### Option B — GitHub Discussion (quickest, no git required) + +Drop a comment in the +[👋 Show & Tell: Who's using Reloader?](https://github.com/stakater/Reloader/discussions/1137) +discussion using this template: + +``` +**Company / Team:** +**Quote:** (1–2 lines on how Reloader helps you) +**Use case:** (e.g. secret rotation, cert-manager, GitOps pipeline) +**Scale:** (clusters, namespaces, workloads — share what you're comfortable with) +**Since:** (approximate year) +**Logo:** (attach an SVG or PNG if you'd like to appear in the grid) +``` + +We'll take care of the PR on your behalf. + +--- + +> **Note:** Anonymous entries are welcome. If you're not able to share your company +> name publicly, you can describe yourself as e.g. *"A fintech running 40 clusters +> in production"* — it still helps the community understand real-world scale. diff --git a/adopters/logos/.gitkeep b/adopters/logos/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/adopters/logos/.gitkeep @@ -0,0 +1 @@ + diff --git a/adopters/logos/exelient-ab.svg b/adopters/logos/exelient-ab.svg new file mode 100644 index 00000000..b382d29c --- /dev/null +++ b/adopters/logos/exelient-ab.svg @@ -0,0 +1 @@ +EXELIENT \ No newline at end of file diff --git a/adopters/logos/stakater-cloud.svg b/adopters/logos/stakater-cloud.svg new file mode 100644 index 00000000..808b70c1 --- /dev/null +++ b/adopters/logos/stakater-cloud.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/deployments/kubernetes/chart/reloader/Chart.yaml b/deployments/kubernetes/chart/reloader/Chart.yaml index 8c4c4508..ee8bae06 100644 --- a/deployments/kubernetes/chart/reloader/Chart.yaml +++ b/deployments/kubernetes/chart/reloader/Chart.yaml @@ -1,8 +1,8 @@ apiVersion: v1 name: reloader description: Reloader chart that runs on kubernetes -version: 2.3.0 -appVersion: v1.4.12 +version: 2.2.12 +appVersion: v1.4.17 keywords: - Reloader - kubernetes diff --git a/deployments/kubernetes/chart/reloader/README.md b/deployments/kubernetes/chart/reloader/README.md index b3ba973f..3e7df648 100644 --- a/deployments/kubernetes/chart/reloader/README.md +++ b/deployments/kubernetes/chart/reloader/README.md @@ -75,6 +75,7 @@ helm uninstall {{RELEASE_NAME}} -n {{NAMESPACE}} | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ----------------- | | `reloader.deployment.replicas` | Number of replicas, if you wish to run multiple replicas set `reloader.enableHA = true`. The replicas will be limited to 1 when `reloader.enableHA = false` | int | 1 | | `reloader.deployment.revisionHistoryLimit` | Limit the number of revisions retained in the revision history | int | 2 | +| `reloader.deployment.hostUsers` | Enable user namespace | boolean | | | `reloader.deployment.nodeSelector` | Scheduling pod to a specific node based on set labels | map | `{}` | | `reloader.deployment.affinity` | Set affinity rules on pod | map | `{}` | | `reloader.deployment.securityContext` | Set pod security context | map | `{}` | @@ -139,21 +140,26 @@ helm uninstall {{RELEASE_NAME}} -n {{NAMESPACE}} #### 🔄 `reloadOnCreate` Behavior **When true:** -✅ New ConfigMaps/Secrets trigger rolling updates -✅ New deployments referencing existing resources reload +✅ New ConfigMaps/Secrets trigger rolling updates for referencing workloads + +**When false:** +❌ ConfigMaps/Secrets creations have no effect on referencing workloads + +#### 🗑️ `reloadOnDelete` Behavior +**When true:** +✅ Deleted ConfigMaps/Secrets trigger rolling updates for referencing workloads + +**When false:** +❌ ConfigMaps/Secrets deletions have no effect on referencing workloads + +#### 🔄 `syncAfterRestart` Behavior +**When true:** ✅ In HA mode, new leader reloads all tracked workloads **When false:** ❌ Updates during leader downtime are missed ⏳ Potential 15s delay window (default `LeaseDuration`) -#### 🗑️ `reloadOnDelete` Behavior -**When true:** -✅ Deleted resources trigger rolling updates of referencing workloads - -**When false:** -❌ Deletions have no effect on referencing pods - #### Default Settings ⚠️ All flags default to `false` (must be enabled explicitly): - `reloadOnCreate` diff --git a/deployments/kubernetes/chart/reloader/templates/clusterrole.yaml b/deployments/kubernetes/chart/reloader/templates/clusterrole.yaml index 11e8a5d4..8f31764f 100644 --- a/deployments/kubernetes/chart/reloader/templates/clusterrole.yaml +++ b/deployments/kubernetes/chart/reloader/templates/clusterrole.yaml @@ -110,6 +110,17 @@ rules: - create - get - update +{{- end}} +{{- if .Values.reloader.enableCSIIntegration }} + - apiGroups: + - "secrets-store.csi.x-k8s.io" + resources: + - secretproviderclasspodstatuses + - secretproviderclasses + verbs: + - list + - get + - watch {{- end}} - apiGroups: - "" diff --git a/deployments/kubernetes/chart/reloader/templates/deployment.yaml b/deployments/kubernetes/chart/reloader/templates/deployment.yaml index 048526e8..7f57b3f5 100644 --- a/deployments/kubernetes/chart/reloader/templates/deployment.yaml +++ b/deployments/kubernetes/chart/reloader/templates/deployment.yaml @@ -47,6 +47,9 @@ spec: {{- if .Values.global.imagePullSecrets }} imagePullSecrets: {{ include "reloader-imagePullSecrets" . | indent 8 }} + {{- end }} + {{- if kindIs "bool" .Values.reloader.deployment.hostUsers }} + hostUsers: {{ .Values.reloader.deployment.hostUsers }} {{- end }} {{- if .Values.reloader.deployment.nodeSelector }} nodeSelector: @@ -212,7 +215,7 @@ spec: {{- . | toYaml | nindent 10 }} {{- end }} {{- end }} - {{- if or (.Values.reloader.logFormat) (.Values.reloader.logLevel) (.Values.reloader.ignoreSecrets) (.Values.reloader.ignoreNamespaces) (include "reloader-namespaceSelector" .) (.Values.reloader.resourceLabelSelector) (.Values.reloader.ignoreConfigMaps) (.Values.reloader.custom_annotations) (eq .Values.reloader.isArgoRollouts true) (eq .Values.reloader.reloadOnCreate true) (eq .Values.reloader.reloadOnDelete true) (ne .Values.reloader.reloadStrategy "default") (.Values.reloader.enableHA) (.Values.reloader.autoReloadAll) (.Values.reloader.ignoreJobs) (.Values.reloader.ignoreCronJobs)}} + {{- if or (.Values.reloader.logFormat) (.Values.reloader.logLevel) (.Values.reloader.ignoreSecrets) (.Values.reloader.ignoreNamespaces) (include "reloader-namespaceSelector" .) (.Values.reloader.resourceLabelSelector) (.Values.reloader.ignoreConfigMaps) (.Values.reloader.custom_annotations) (eq .Values.reloader.isArgoRollouts true) (eq .Values.reloader.reloadOnCreate true) (eq .Values.reloader.reloadOnDelete true) (ne .Values.reloader.reloadStrategy "default") (.Values.reloader.enableHA) (.Values.reloader.autoReloadAll) (.Values.reloader.ignoreJobs) (.Values.reloader.ignoreCronJobs) (.Values.reloader.enableCSIIntegration)}} args: {{- if .Values.reloader.logFormat }} - "--log-format={{ .Values.reloader.logFormat }}" @@ -224,7 +227,7 @@ spec: - "--resources-to-ignore=secrets" {{- end }} {{- if .Values.reloader.ignoreConfigMaps }} - - "--resources-to-ignore=configMaps" + - "--resources-to-ignore=configmaps" {{- end }} {{- if and (.Values.reloader.ignoreJobs) (.Values.reloader.ignoreCronJobs) }} - "--ignored-workload-types=jobs,cronjobs" @@ -248,6 +251,9 @@ spec: - "--pprof-addr={{ .Values.reloader.pprofAddr }}" {{- end }} {{- end }} + {{- if .Values.reloader.enableCSIIntegration }} + - "--enable-csi-integration=true" + {{- end }} {{- if .Values.reloader.custom_annotations }} {{- if .Values.reloader.custom_annotations.configmap }} - "--configmap-annotation" @@ -276,6 +282,10 @@ spec: {{- if .Values.reloader.custom_annotations.match }} - "--search-match-annotation" - "{{ .Values.reloader.custom_annotations.match }}" + {{- end }} + {{- if .Values.reloader.custom_annotations.ignore }} + - "--ignore-annotation" + - "{{ .Values.reloader.custom_annotations.ignore }}" {{- end }} {{- if .Values.reloader.custom_annotations.pausePeriod }} - "--pause-deployment-annotation" diff --git a/deployments/kubernetes/chart/reloader/templates/role.yaml b/deployments/kubernetes/chart/reloader/templates/role.yaml index c6cfed64..495a3855 100644 --- a/deployments/kubernetes/chart/reloader/templates/role.yaml +++ b/deployments/kubernetes/chart/reloader/templates/role.yaml @@ -97,6 +97,17 @@ rules: - create - get - update +{{- end}} +{{- if .Values.reloader.enableCSIIntegration }} + - apiGroups: + - "secrets-store.csi.x-k8s.io" + resources: + - secretproviderclasspodstatuses + - secretproviderclasses + verbs: + - list + - get + - watch {{- end}} - apiGroups: - "" diff --git a/deployments/kubernetes/chart/reloader/values.yaml b/deployments/kubernetes/chart/reloader/values.yaml index c9a46a07..66d00f21 100644 --- a/deployments/kubernetes/chart/reloader/values.yaml +++ b/deployments/kubernetes/chart/reloader/values.yaml @@ -19,7 +19,7 @@ fullnameOverride: "" image: name: stakater/reloader repository: ghcr.io/stakater/reloader - tag: v1.4.12 + tag: v1.4.17 # digest: sha256:1234567 pullPolicy: IfNotPresent @@ -49,6 +49,7 @@ reloader: enableHA: false # Set to true to enable pprof for profiling enablePProf: false + enableCSIIntegration: false # Address to start pprof server on. Default is ":6060" pprofAddr: ":6060" # Set to true if you have a pod security policy that enforces readOnlyRootFilesystem @@ -77,6 +78,8 @@ reloader: revisionHistoryLimit: 2 + hostUsers: null + nodeSelector: # cloud.google.com/gke-nodepool: default-pool @@ -132,7 +135,7 @@ reloader: labels: provider: stakater group: com.stakater.platform - version: v1.4.12 + version: v1.4.14 # Support for extra environment variables. env: # Open supports Key value pair as environment variables. @@ -212,6 +215,7 @@ reloader: # custom_annotations: # configmap: "my.company.com/configmap" # secret: "my.company.com/secret" + # ignore: "my.company.com/reloader-ignore" custom_annotations: {} serviceMonitor: diff --git a/deployments/kubernetes/manifests/deployment.yaml b/deployments/kubernetes/manifests/deployment.yaml index e27dae96..75c66f5e 100644 --- a/deployments/kubernetes/manifests/deployment.yaml +++ b/deployments/kubernetes/manifests/deployment.yaml @@ -17,7 +17,7 @@ spec: app: reloader-reloader spec: containers: - - image: "ghcr.io/stakater/reloader:v1.4.12" + - image: "ghcr.io/stakater/reloader:v1.4.14" imagePullPolicy: IfNotPresent name: reloader-reloader env: diff --git a/deployments/kubernetes/reloader.yaml b/deployments/kubernetes/reloader.yaml index 334b2a75..bcd376b3 100644 --- a/deployments/kubernetes/reloader.yaml +++ b/deployments/kubernetes/reloader.yaml @@ -141,7 +141,7 @@ spec: fieldPath: metadata.namespace - name: RELOADER_DEPLOYMENT_NAME value: reloader-reloader - image: ghcr.io/stakater/reloader:v1.4.12 + image: ghcr.io/stakater/reloader:v1.4.14 imagePullPolicy: IfNotPresent livenessProbe: failureThreshold: 5 diff --git a/docs-nginx.conf b/docs-nginx.conf deleted file mode 100644 index f3897143..00000000 --- a/docs-nginx.conf +++ /dev/null @@ -1,11 +0,0 @@ -server { - listen 8080; - root /usr/share/nginx/html/; - index index.html; - error_page 403 404 /404.html; - location = /404.html { - internal; - } - # redirects issued by nginx will be relative - absolute_redirect off; -} diff --git a/docs/Alerting.md b/docs/Alerting.md deleted file mode 100644 index bb4fbbec..00000000 --- a/docs/Alerting.md +++ /dev/null @@ -1,18 +0,0 @@ -# Alerting on Reload - -Reloader can alert when it triggers a rolling upgrade on Deployments or StatefulSets. Webhook notification alert would be sent to the configured webhook server with all the required information. - -## Enabling - -In-order to enable this feature, you need to update the `reloader.env.secret` section of `values.yaml` providing the information needed for alert: - -```yaml - ALERT_ON_RELOAD: [ true/false ] Default: false - ALERT_SINK: [ slack/teams/gchat/webhook ] Default: webhook - ALERT_WEBHOOK_URL: Required if ALERT_ON_RELOAD is true - ALERT_ADDITIONAL_INFO: Any additional information to be added to alert -``` - -## Slack Incoming-Webhook Creation Docs - -[Sending messages using Incoming Webhooks](https://api.slack.com/messaging/webhooks) diff --git a/docs/Container Build.md b/docs/Container Build.md deleted file mode 100644 index d48d438b..00000000 --- a/docs/Container Build.md +++ /dev/null @@ -1,53 +0,0 @@ -# Container Build - -> **WARNING:** As a user of Reloader there is no need to build containers, the open source version is available on [Docker Hub](https://hub.docker.com/r/stakater/reloader/). - -Multi-architecture approach is based on original work by [@mdh02038](https://github.com/mdh02038/Reloader). - -Images are tested on linux/arm, linux/arm64 and linux/amd64. - -## Install Pre-Reqs - -The build environment requires the following packages (tested on `Ubuntu 20.04`): - -* Golang -* `make` -* `qemu` (for arm, arm64 etc. emulation) -* binfmt-support -* Docker engine - -## Docker - -Follow instructions on [Install using the apt repository](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository). - -Once installed, enable the experimental CLI: - -```bash -export DOCKER_CLI_EXPERIMENTAL=enabled -``` - -Login to enable publishing of packages: - -```bash -sudo docker login -``` - -## Remaining Pre-Reqs - -Remaining Pre-Reqs can be installed via: - -```bash -sudo apt install golang make qemu-user-static binfmt-support -y -``` - -## Publish Multi-Architecture Image - -To build/ publish multi-arch Docker images clone repository and execute from repository root: - -```bash -sudo make release-all -``` - -## Additional Links/Info - -[Building Multi-Architecture Docker Images With `Buildx`](https://medium.com/@artur.klauser/building-multi-architecture-docker-images-with-buildx-27d80f7e2408) diff --git a/docs/Helm2-to-Helm3.md b/docs/Helm2-to-Helm3.md deleted file mode 100644 index c55eae1c..00000000 --- a/docs/Helm2-to-Helm3.md +++ /dev/null @@ -1,68 +0,0 @@ -# Helm2 to Helm3 Migration - -Follow below-mentioned instructions to migrate Reloader from Helm2 to Helm3 - -## Instructions - -There are 3 steps involved in migrating the Reloader from Helm2 to Helm3. - -### Step 1 - -Install the `helm-2to3` plugin - -```bash -helm3 plugin install https://github.com/helm/helm-2to3 - -helm3 2to3 convert - -helm3 2to3 cleanup --release-cleanup --skip-confirmation -``` - -### Step 2 - -Add the following Helm3 labels and annotations on Reloader resources. - -Label: - -```yaml -app.kubernetes.io/managed-by=Helm -``` - -Annotations: - -```yaml -meta.helm.sh/release-name= -meta.helm.sh/release-namespace= -``` - -For example, to label and annotate the ClusterRoleBinding and ClusterRole: - -```bash -KIND=ClusterRoleBinding -NAME=reloader-reloader-role-binding -RELEASE=reloader -NAMESPACE=kube-system -kubectl annotate $KIND $NAME meta.helm.sh/release-name=$RELEASE -kubectl annotate $KIND $NAME meta.helm.sh/release-namespace=$NAMESPACE -kubectl label $KIND $NAME app.kubernetes.io/managed-by=Helm - -KIND=ClusterRole -NAME=reloader-reloader-role -RELEASE=reloader -NAMESPACE=kube-system -kubectl annotate $KIND $NAME meta.helm.sh/release-name=$RELEASE -kubectl annotate $KIND $NAME meta.helm.sh/release-namespace=$NAMESPACE -kubectl label $KIND $NAME app.kubernetes.io/managed-by=Helm -``` - -### Step 3 - -Upgrade to desired version - -```bash -helm3 repo add stakater https://stakater.github.io/stakater-charts - -helm3 repo update - -helm3 upgrade stakater/reloader --version=v0.0.72 -``` diff --git a/docs/How-it-works.md b/docs/How-it-works.md deleted file mode 100644 index c0ae964f..00000000 --- a/docs/How-it-works.md +++ /dev/null @@ -1,93 +0,0 @@ -# How Does Reloader Work? - -Reloader watches for `ConfigMap` and `Secret` and detects if there are changes in data of these objects. After change detection Reloader performs rolling upgrade on relevant Pods via associated `Deployment`, `Daemonset` and `Statefulset`: - -```mermaid -flowchart LR - subgraph Reloader - controller("Controller watches in a loop") -- "Detects a change" --> upgrade_handler("Upgrade handler checks if the change is a valid data change by comparing the change hash") - upgrade_handler -- "Update resource" --> update_resource("Updates the resource with computed hash of change") - end - Reloader -- "Watches" --> secret_configmaps("Secrets/ConfigMaps") - Reloader -- "Updates resources with Reloader environment variable" --> resources("Deployments/DaemonSets/StatefulSets resources with Reloader annotation") - resources -- "Restart pods based on StrategyType" --> Pods -``` - -## How Does Change Detection Work? - -Reloader watches changes in `ConfigMaps` and `Secrets` data. As soon as it detects a change in these. It forwards these objects to an update handler which decides if and how to perform the rolling upgrade. - -## Requirements for Rolling Upgrade - -To perform rolling upgrade a `deployment`, `daemonset` or `statefulset` must have - -- support for rolling upgrade strategy -- specific annotation for `ConfigMaps` or `Secrets` - -The annotation value is comma separated list of `ConfigMaps` or `Secrets`. If a change is detected in data of these `ConfigMaps` or `Secrets`, Reloader will perform rolling upgrades on their associated `deployments`, `daemonsets` or `statefulsets`. - -### Annotation for ConfigMap - -For a `Deployment` called `foo` have a `ConfigMap` called `foo`. Then add this annotation* to your `Deployment`, where the default annotation can be changed with the `--configmap-annotation` flag: - -```yaml -metadata: - annotations: - configmap.reloader.stakater.com/reload: "foo" -``` - -### Annotation for Secret - -For a `Deployment` called `foo` have a `Secret` called `foo`. Then add this annotation to your `Deployment`, where the default annotation can be changed with the `--secret-annotation` flag: - -```yaml -metadata: - annotations: - secret.reloader.stakater.com/reload: "foo" -``` - -Above mentioned annotation are also work for `Daemonsets` `Statefulsets` and `Rollouts` - -## How Does Rolling Upgrade Work? - -When Reloader detects changes in `ConfigMap`. It gets two objects of `ConfigMap`. First object is an old `ConfigMap` object which has a state before the latest change. Second object is new `ConfigMap` object which contains latest changes. Reloader compares both objects and see whether any change in data occurred or not. If Reloader finds any change in new `ConfigMap` object, only then, it moves forward with rolling upgrade. - -After that, Reloader gets the list of all `deployments`, `daemonsets` and `statefulset` and looks for above mentioned annotation for `ConfigMap`. If the annotation value contains the `ConfigMap` name, it then looks for an environment variable which can contain the `ConfigMap` or secret data change hash. - -### Environment Variable for ConfigMap - -If `ConfigMap` name is foo then - -```yaml -STAKATER_FOO_CONFIGMAP -``` - -### Environment Variable for Secret - -If Secret name is foo then - -```yaml -STAKATER_FOO_SECRET -``` - -If the environment variable is found then it gets its value and compares it with new `ConfigMap` hash value. If old value in environment variable is different from new hash value then Reloader updates the environment variable. If the environment variable does not exist then it creates a new environment variable with latest hash value from `ConfigMap` and updates the relevant `deployment`, `daemonset` or `statefulset` - -Note: Rolling upgrade also works in the same way for secrets. - -### Hash Value Computation - -Reloader uses SHA1 to compute hash value. SHA1 is used because it is efficient and less prone to collision. - -## Monitor All Namespaces - -By default Reloader deploys in default namespace and monitors changes in all namespaces. To monitor changes in a specific namespace deploy the Reloader in that namespace and set the `watchGlobally` flag to `false` in values file located under `deployments/kubernetes/chart/reloader` and render manifest file using helm command: - -```bash -helm --namespace {replace this with namespace name} template . > reloader.yaml -``` - -The output file can then be used to deploy Reloader in specific namespace. - -## Compatibility With Helm Install and Upgrade - -Reloader has no impact on helm deployment cycle. Reloader only injects an environment variable in `deployment`, `daemonset` or `statefulset`. The environment variable contains the SHA1 value of `ConfigMaps` or `Secrets` data. So if a deployment is created using Helm and Reloader updates the deployment, then next time you upgrade the helm release, Reloader will do nothing except changing that environment variable value in `deployment` , `daemonset` or `statefulset`. diff --git a/docs/Reloader-vs-ConfigmapController.md b/docs/Reloader-vs-ConfigmapController.md deleted file mode 100644 index f866f898..00000000 --- a/docs/Reloader-vs-ConfigmapController.md +++ /dev/null @@ -1,11 +0,0 @@ -# Reloader vs ConfigmapController - -Reloader is inspired from [`configmapcontroller`](https://github.com/fabric8io/configmapcontroller) but there are many ways in which it differs from `configmapcontroller`. Below is the small comparison between these two controllers. - -| Reloader | ConfigMap | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Reloader can watch both `Secrets` and `ConfigMaps`. | `configmapcontroller` can only watch changes in `ConfigMaps`. It cannot detect changes in other resources like `Secrets`. | -| Reloader can perform rolling upgrades on `deployments` as well as on `statefulsets` and `daemonsets` | `configmapcontroller` can only perform rolling upgrades on `deployments`. It currently does not support rolling upgrades on `statefulsets` and `daemonsets` | -| Reloader provides both unit test cases and end to end integration test cases for future updates. So one can make sure that new changes do not break any old functionality. | Currently there are not any unit test cases or end to end integration test cases in `configmap-controller`. It add difficulties for any additional updates in `configmap-controller` and one can not know for sure whether new changes breaks any old functionality or not. | -| Reloader uses SHA1 to encode the change in `ConfigMap` or `Secret`. It then saves the SHA1 value in `STAKATER_FOO_CONFIGMAP` or `STAKATER_FOO_SECRET` environment variable depending upon where the change has happened. The use of SHA1 provides a concise 40 characters encoded value that is very less prone to collision. | `configmap-controller` uses `FABRICB_FOO_REVISION` environment variable to store any change in `ConfigMap` controller. It does not encode it or convert it in suitable hash value to avoid data pollution in deployment. | -| Reloader allows you to customize your own annotation (for both `Secrets` and `ConfigMaps`) using command line flags | `configmap-controller` restricts you to only their provided annotation | diff --git a/docs/Reloader-vs-k8s-trigger-controller.md b/docs/Reloader-vs-k8s-trigger-controller.md deleted file mode 100644 index 811987a2..00000000 --- a/docs/Reloader-vs-k8s-trigger-controller.md +++ /dev/null @@ -1,46 +0,0 @@ -# Reloader vs k8s-trigger-controller - -Reloader and k8s-trigger-controller are both built for same purpose. So there are quite a few similarities and differences between these. - -## Similarities - -- Both controllers support change detection in `ConfigMaps` and `Secrets` -- Both controllers support deployment `rollout` -- Both controllers use SHA1 for hashing -- Both controllers have end to end as well as unit test cases. - -## Differences - -### Support for `Daemonsets` and `Statefulsets` - -#### `k8s-trigger-controller` - -`k8s-trigger-controller` only support for deployment `rollout`. It does not support `daemonsets` and `statefulsets` `rollout`. - -#### Reloader - -Reloader supports deployment `rollout` as well as `daemonsets` and `statefulsets` `rollout`. - -### Hashing Usage - -#### `k8s-trigger-controller` - -`k8s-trigger-controller` stores the hash value in an annotation `trigger.k8s.io/[secret|configMap]-NAME-last-hash` - -#### Reloader - -Reloader stores the hash value in an environment variable `STAKATER_NAME_[SECRET|CONFIGMAP]` - -### Customization - -#### `k8s-trigger-controller` - -`k8s-trigger-controller` restricts you to using the `trigger.k8s.io/[secret-configMap]-NAME-last-hash` annotation - -#### Reloader - -Reloader allows you to customize the annotation to fit your needs with command line flags: - -- `--auto-annotation ` -- `--configmap-annotation ` -- `--secret-annotation ` diff --git a/docs/Reloader-with-Sealed-Secrets.md b/docs/Reloader-with-Sealed-Secrets.md deleted file mode 100644 index 4df328d6..00000000 --- a/docs/Reloader-with-Sealed-Secrets.md +++ /dev/null @@ -1,14 +0,0 @@ -# Using Reloader with Sealed Secrets - -Below are the steps to use Reloader with Sealed Secrets: - -1. Download and install the kubeseal client from [here](https://github.com/bitnami-labs/sealed-secrets) -1. Install the controller for Sealed Secrets -1. Fetch the encryption certificate -1. Encrypt the secret -1. Apply the secret -1. Install the tool which uses that Sealed Secret -1. Install Reloader -1. Once everything is setup, update the original secret at client and encrypt it with kubeseal to see Reloader working -1. Apply the updated Sealed Secret -1. Reloader will restart the pod to use that updated secret diff --git a/docs/Verify-Reloader-Working.md b/docs/Verify-Reloader-Working.md deleted file mode 100644 index 1e9146fa..00000000 --- a/docs/Verify-Reloader-Working.md +++ /dev/null @@ -1,75 +0,0 @@ -# Verify Reloader's Working - -Reloader's working can be verified by three ways. - -## Verify From Logs - -Check the logs of Reloader and verify that you can see logs looks like below, if you are able to find these logs then it means Reloader is working. - -```text -Changes Detected in test-object of type 'SECRET' in namespace: test-reloader - -Updated test-resource of type Deployment in namespace: test-reloader -``` - -Below are the details that explain these logs: - -### `test-object` - -`test-object` is the name of a `secret` or a `configmap` in which change has been detected. - -### `SECRET` - -`SECRET` is the type of `test-object`. It can either be `SECRET` or `CONFIGMAP` - -### `test-reloader` - -`test-reloader` is the name of namespace in which Reloader has detected the change. - -### `test-resource` - -`test-resource` is the name of resource which is going to be updated - -### `Deployment` - -`Deployment` is the type of `test-resource`. It can either be a `Deployment`, `Daemonset` or `Statefulset` - -## Verify by Checking the Age of Pod - -A pod's age can tell whether Reloader is working correctly or not. If you know that a change in a `secret` or `configmap` has occurred, then check the relevant Pod's age immediately. It should be newly created few moments ago. - -### Verify from Kubernetes Dashboard - -`kubernetes dashboard` can be used to verify the working of Reloader. After a change in `secret` or `configmap`, check the relevant Pod's age from dashboard. It should be newly created few moments ago. - -### Verify from Command Line - -After a change in `secret` or `configmap`. Run the below-mentioned command and verify that the pod is newly created. - -```bash -kubectl get pods -n -``` - -## Verify From Metrics - -Some metrics are exported to Prometheus endpoint `/metrics` on port `9090`. - -When Reloader is unable to reload, `reloader_reload_executed_total{success="false"}` metric gets incremented and when it reloads successfully, `reloader_reload_executed_total{success="true"}` gets incremented. You will be able to see the following metrics, with some other metrics, at `/metrics` endpoint. - -```text -reloader_reload_executed_total{success="false"} 15 -reloader_reload_executed_total{success="true"} 12 -``` - -### Reloads by Namespace - -Reloader can also export a metric to show the number of reloads by namespace. This feature is disabled by default, as it can lead to high cardinality in clusters with many namespaces. - -The metric will have both `success` and `namespace` as attributes: - -```text -reloader_reload_executed_total{success="false", namespace="some-namespace"} 2 -reloader_reload_executed_total{success="true", namespace="some-namespace"} 1 -``` - -To opt in, set the environment variable `METRICS_COUNT_BY_NAMESPACE` to `enabled` or set the Helm value `reloader.enableMetricsByNamespace` to `true`. diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 11971869..00000000 --- a/docs/index.md +++ /dev/null @@ -1,26 +0,0 @@ -# Introduction - -Reloader can watch changes in `ConfigMap` and `Secret` and do rolling upgrades on Pods with their associated `DeploymentConfigs`, `Deployments`, `Daemonsets` `Statefulsets` and `Rollouts`. - -These are the key features of Reloader: - -1. Restart pod in a `deployment` on change in linked/related `ConfigMaps` or `Secrets` -1. Restart pod in a `daemonset` on change in linked/related `ConfigMaps` or `Secrets` -1. Restart pod in a `statefulset` on change in linked/related `ConfigMaps` or `Secrets` -1. Restart pod in a `rollout` on change in linked/related `ConfigMaps` or `Secrets` - -This site contains more details on how Reloader works. For an overview, please see the repository's [README file](https://github.com/stakater/Reloader/blob/master/README.md). - ---- - -
- -[![💖 Sponsor our work](https://img.shields.io/badge/Sponsor%20Our%20Work-FF8C00?style=for-the-badge&logo=github-sponsors&logoColor=white)](https://github.com/sponsors/stakater?utm_source=docs&utm_medium=footer&utm_campaign=reloader) - -

-Your support funds maintenance, security updates, and new features for Reloader, plus continued investment in other open source tools. -

- -
- ---- diff --git a/go.mod b/go.mod index ed633b39..a6d752df 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,27 @@ module github.com/stakater/Reloader -go 1.25.5 +go 1.26.3 require ( - github.com/argoproj/argo-rollouts v1.8.3 + github.com/argoproj/argo-rollouts v1.9.0 github.com/go-logr/logr v1.4.3 github.com/go-logr/zerologr v1.2.3 - github.com/openshift/api v0.0.0-20260107103503-6d35063ca179 - github.com/openshift/client-go v0.0.0-20260105124352-f93a4291f9ae + github.com/onsi/ginkgo/v2 v2.27.4 + github.com/onsi/gomega v1.39.0 + github.com/openshift/api v0.0.0-20260402111718-ad9eb11110b6 + github.com/openshift/client-go v0.0.0-20260330134249-7e1499aaacd7 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 - github.com/rs/zerolog v1.34.0 + github.com/rs/zerolog v1.35.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - github.com/spf13/viper v1.21.0 - k8s.io/api v0.35.0 - k8s.io/apimachinery v0.35.0 - k8s.io/client-go v0.35.0 - sigs.k8s.io/controller-runtime v0.22.4 + github.com/spf13/viper v1.12.0 + k8s.io/api v0.36.0 + k8s.io/apimachinery v0.36.0 + k8s.io/client-go v0.36.0 + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 + sigs.k8s.io/controller-runtime v0.24.1 + sigs.k8s.io/secrets-store-csi-driver v1.5.5 ) require ( @@ -82,25 +86,26 @@ require ( github.com/fatih/structtag v1.2.0 // indirect github.com/firefart/nonamedreturns v1.0.6 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect github.com/ghostiam/protogetter v0.3.18 // indirect github.com/go-critic/go-critic v0.14.3 // indirect github.com/go-errors/errors v1.4.2 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/jsonreference v0.21.4 // indirect - github.com/go-openapi/swag v0.25.4 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/conv v0.25.4 // indirect - github.com/go-openapi/swag/fileutils v0.25.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.4 // indirect - github.com/go-openapi/swag/loading v0.25.4 // indirect - github.com/go-openapi/swag/mangling v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect - github.com/go-openapi/swag/stringutils v0.25.4 // indirect - github.com/go-openapi/swag/typeutils v0.25.4 // indirect - github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/swag v0.25.5 // indirect + github.com/go-openapi/swag/cmdutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.25.5 // indirect + github.com/go-openapi/swag/netutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astequal v1.2.0 // indirect @@ -125,11 +130,12 @@ require ( github.com/golangci/revgrep v0.8.0 // indirect github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect - github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260106004452-d7df1bf2cac7 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gordonklaus/ineffassign v0.2.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect @@ -137,6 +143,7 @@ require ( github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect github.com/hashicorp/go-version v1.8.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jgautheron/goconst v1.8.2 // indirect @@ -159,6 +166,7 @@ require ( github.com/leonklingele/grouper v1.1.2 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/macabu/inamedparam v0.2.0 // indirect + github.com/magiconair/properties v1.8.6 // indirect github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect github.com/manuelarte/funcorder v0.5.0 // indirect github.com/maratori/testableexamples v1.0.1 // indirect @@ -169,6 +177,8 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mgechev/revive v1.13.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moby/spdystream v0.5.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect @@ -179,12 +189,11 @@ require ( github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.21.2 // indirect - github.com/onsi/ginkgo/v2 v2.27.3 // indirect - github.com/onsi/gomega v1.38.3 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/quasilyte/go-ruleguard v0.4.5 // indirect github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect github.com/quasilyte/gogrep v0.5.0 // indirect @@ -195,24 +204,24 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/ryancurrah/gomodguard v1.4.1 // indirect github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect - github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sashamelentyev/interfacebloat v1.1.0 // indirect github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect github.com/securego/gosec/v2 v2.22.11 // indirect - github.com/sergi/go-diff v1.4.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect + github.com/sergi/go-diff v1.2.0 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/sivchari/containedctx v1.0.3 // indirect github.com/sonatard/noctx v0.4.0 // indirect github.com/sourcegraph/go-diff v0.7.0 // indirect github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.11.1 // indirect - github.com/subosito/gotenv v1.6.0 // indirect + github.com/subosito/gotenv v1.4.1 // indirect github.com/tetafro/godot v1.5.4 // indirect github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect github.com/timonwong/loggercheck v0.11.0 // indirect @@ -236,65 +245,71 @@ require ( go.augendre.info/fatcontext v0.9.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.40.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.44.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.6.1 // indirect - k8s.io/apiextensions-apiserver v0.35.0 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect - k8s.io/utils v0.0.0-20260106112306-0fe9cd71b2f8 // indirect + k8s.io/apiextensions-apiserver v0.36.0 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect + k8s.io/streaming v0.36.0 // indirect mvdan.cc/gofumpt v0.9.2 // indirect mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/kustomize/api v0.21.0 // indirect - sigs.k8s.io/kustomize/cmd/config v0.21.0 // indirect - sigs.k8s.io/kustomize/kustomize/v5 v5.8.0 // indirect - sigs.k8s.io/kustomize/kyaml v0.21.0 // indirect + sigs.k8s.io/kustomize/api v0.20.1 // indirect + sigs.k8s.io/kustomize/cmd/config v0.20.1 // indirect + sigs.k8s.io/kustomize/kustomize/v5 v5.7.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) +tool ( + github.com/golangci/golangci-lint/v2/cmd/golangci-lint + github.com/onsi/ginkgo/v2/ginkgo + golang.org/x/tools/cmd/goimports +) + // Replacements for argo-rollouts replace ( github.com/go-check/check => github.com/go-check/check v0.0.0-20201130134442-10cb98267c6c - k8s.io/api v0.0.0 => k8s.io/api v0.32.3 - k8s.io/apimachinery v0.0.0 => k8s.io/apimachinery v0.32.3 - k8s.io/client-go v0.0.0 => k8s.io/client-go v0.32.3 + k8s.io/api v0.0.0 => k8s.io/api v0.35.3 + k8s.io/apimachinery v0.0.0 => k8s.io/apimachinery v0.35.3 + k8s.io/client-go v0.0.0 => k8s.io/client-go v0.35.3 k8s.io/cloud-provider v0.0.0 => k8s.io/cloud-provider v0.24.2 - k8s.io/controller-manager v0.0.0 => k8s.io/controller-manager v0.24.2 + k8s.io/controller-manager v0.0.0 => k8s.io/controller-manager v0.35.3 k8s.io/cri-api v0.0.0 => k8s.io/cri-api v0.20.5-rc.0 k8s.io/csi-translation-lib v0.0.0 => k8s.io/csi-translation-lib v0.24.2 k8s.io/kube-aggregator v0.0.0 => k8s.io/kube-aggregator v0.24.2 k8s.io/kube-controller-manager v0.0.0 => k8s.io/kube-controller-manager v0.24.2 k8s.io/kube-proxy v0.0.0 => k8s.io/kube-proxy v0.24.2 k8s.io/kube-scheduler v0.0.0 => k8s.io/kube-scheduler v0.24.2 - k8s.io/kubectl v0.0.0 => k8s.io/kubectl v0.32.3 - k8s.io/kubelet v0.0.0 => k8s.io/kubelet v0.24.2 + k8s.io/kubectl v0.0.0 => k8s.io/kubectl v0.35.3 + k8s.io/kubelet v0.0.0 => k8s.io/kubelet v0.35.3 k8s.io/legacy-cloud-providers v0.0.0 => k8s.io/legacy-cloud-providers v0.24.2 - k8s.io/mount-utils v0.0.0 => k8s.io/mount-utils v0.20.5-rc.0 + k8s.io/mount-utils v0.0.0 => k8s.io/mount-utils v0.35.3 k8s.io/sample-apiserver v0.0.0 => k8s.io/sample-apiserver v0.24.2 k8s.io/sample-cli-plugin v0.0.0 => k8s.io/sample-cli-plugin v0.24.2 k8s.io/sample-controller v0.0.0 => k8s.io/sample-controller v0.24.2 ) -tool ( - github.com/golangci/golangci-lint/v2/cmd/golangci-lint - sigs.k8s.io/kustomize/kustomize/v5 -) +tool sigs.k8s.io/kustomize/kustomize/v5 diff --git a/go.sum b/go.sum index 94586709..be593f22 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,10 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= -github.com/argoproj/argo-rollouts v1.8.3 h1:blbtQva4IK9r6gFh+dWkCrLnFdPOWiv9ubQYu36qeaA= -github.com/argoproj/argo-rollouts v1.8.3/go.mod h1:kCAUvIfMGfOyVf3lvQbBt0nqQn4Pd+zB5/YwKv+UBa8= +github.com/argoproj/argo-rollouts v1.9.0 h1:bXgBpwCByXyAUcgBnyP0fxkSW2CEot78InTFjFlag5g= +github.com/argoproj/argo-rollouts v1.9.0/go.mod h1:jOalqf2kDSmCp7eQpFF4i3kHnlEqNE/Yjwz1q7CpPIU= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTir2UZzSxo= github.com/ashanbrown/forbidigo/v2 v2.3.0/go.mod h1:5p6VmsG5/1xx3E785W9fouMxIOkvY2rRV9nMdWadd6c= github.com/ashanbrown/makezero/v2 v2.1.0 h1:snuKYMbqosNokUKm+R6/+vOPs8yVAi46La7Ck6QYSaE= @@ -100,7 +102,6 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= @@ -122,8 +123,8 @@ github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bF github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= -github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= -github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -136,12 +137,18 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= github.com/ghostiam/protogetter v0.3.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo= github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -152,40 +159,40 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= -github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= -github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= -github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= -github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= -github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= -github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= -github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= -github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= -github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= -github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= -github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= -github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= -github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= -github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= -github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= -github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= -github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= -github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= -github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= -github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= -github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= -github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= +github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= +github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= +github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= +github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= +github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -215,7 +222,8 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godoc-lint/godoc-lint v0.11.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA= github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= @@ -244,8 +252,6 @@ github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2b github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -256,12 +262,14 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/pprof v0.0.0-20260106004452-d7df1bf2cac7 h1:kmPAX+IJBcUAFTddx2+xC0H7sk2U9ijIIxZLLrPLNng= +github.com/google/pprof v0.0.0-20260106004452-d7df1bf2cac7/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= @@ -283,6 +291,8 @@ github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bP github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -293,6 +303,8 @@ github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjz github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= @@ -340,6 +352,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= +github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= +github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM= github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8= @@ -348,23 +362,28 @@ github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6 github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ= github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs= github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/mgechev/revive v1.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM= github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -387,14 +406,14 @@ github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= github.com/nunnatsa/ginkgolinter v0.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po= github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY= -github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8= -github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= -github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= -github.com/openshift/api v0.0.0-20260107103503-6d35063ca179 h1:5gMFMmuVLAcEnBAjNFql/8L2ZRPBDOxl7nmbjO5klvk= -github.com/openshift/api v0.0.0-20260107103503-6d35063ca179/go.mod h1:d5uzF0YN2nQQFA0jIEWzzOZ+edmo6wzlGLvx5Fhz4uY= -github.com/openshift/client-go v0.0.0-20260105124352-f93a4291f9ae h1:veyDeAOBVJun1KoOsTIRlD7Q5LwRR32kfS2IPjPXJKE= -github.com/openshift/client-go v0.0.0-20260105124352-f93a4291f9ae/go.mod h1:leoeMrUnO40DwByGl7we2l+h6HQq3Y6bHUa+DnmRl+8= +github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= +github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/openshift/api v0.0.0-20260402111718-ad9eb11110b6 h1:y6bV2fLI5CgnSwJ03OuO/2PdLMvTLVGbX63UZ/HBVaI= +github.com/openshift/api v0.0.0-20260402111718-ad9eb11110b6/go.mod h1:pyVjK0nZ4sRs4fuQVQ4rubsJdahI1PB94LnQ8sGdvxo= +github.com/openshift/client-go v0.0.0-20260330134249-7e1499aaacd7 h1:5GSoQlywIwYsRCw3qN+ZDmN6HrXTMZfI33bdRNm2jRQ= +github.com/openshift/client-go v0.0.0-20260330134249-7e1499aaacd7/go.mod h1:HhXTUIMhgzxR3Ln/zEkr4QjTL0NN7A+t9Py/we9j2ug= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= @@ -402,12 +421,15 @@ github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJ github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -416,8 +438,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= @@ -435,16 +457,13 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= -github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= -github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g= github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU= github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= -github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= -github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= @@ -455,12 +474,12 @@ github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iM github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg= github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o= @@ -469,16 +488,18 @@ github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCp github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= +github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= @@ -486,19 +507,28 @@ github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUe github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= +github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tetafro/godot v1.5.4 h1:u1ww+gqpRLiIA16yF2PV1CV1n/X3zhyezbNXC3E14Sg= github.com/tetafro/godot v1.5.4/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= @@ -554,10 +584,10 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -566,8 +596,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= +golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE= @@ -581,8 +611,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -596,10 +626,10 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -609,8 +639,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -624,24 +654,24 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -650,10 +680,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= @@ -668,8 +698,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -678,10 +708,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= -gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -690,46 +720,54 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= -k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= -k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= -k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= -k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= -k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= -k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= -k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ= -k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20260106112306-0fe9cd71b2f8 h1:oV4uULAC2QPIdMQwjMaNIwykyhWhnhBwX40yd5h9u3U= -k8s.io/utils v0.0.0-20260106112306-0fe9cd71b2f8/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= +k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= +k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= +k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= +k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= +k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= +k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= +k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/streaming v0.36.0 h1:agnTxU+NFulUrtYzXUGKO3ndEa8jKwht1Kwn9nu9x+4= +k8s.io/streaming v0.36.0/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= -sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= -sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/kustomize/api v0.21.0 h1:I7nry5p8iDJbuRdYS7ez8MUvw7XVNPcIP5GkzzuXIIQ= -sigs.k8s.io/kustomize/api v0.21.0/go.mod h1:XGVQuR5n2pXKWbzXHweZU683pALGw/AMVO4zU4iS8SE= -sigs.k8s.io/kustomize/cmd/config v0.21.0 h1:ikLtzcNK9isBqSaXXhAg7LRCTNKdp70z5v/c4Y55DOw= -sigs.k8s.io/kustomize/cmd/config v0.21.0/go.mod h1:oxa6eRzeLWUcE7M3Rmio29Sfc4KpqGspHur3GjOYqNA= -sigs.k8s.io/kustomize/kustomize/v5 v5.8.0 h1:CCIJK7z/xJOlkXOaDOcL2jprV53a/eloiL02wg7oJJs= -sigs.k8s.io/kustomize/kustomize/v5 v5.8.0/go.mod h1:qewGAExYZK9LbPPbnJMPK5HQ8nsdxRzpclIg0qslzDo= -sigs.k8s.io/kustomize/kyaml v0.21.0 h1:7mQAf3dUwf0wBerWJd8rXhVcnkk5Tvn/q91cGkaP6HQ= -sigs.k8s.io/kustomize/kyaml v0.21.0/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= +sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= +sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= +sigs.k8s.io/kustomize/cmd/config v0.20.1 h1:4APUORmZe2BYrsqgGfEKdd/r7gM6i43egLrUzilpiFo= +sigs.k8s.io/kustomize/cmd/config v0.20.1/go.mod h1:R7rQ8kxknVlXWVUIbxWtMgu8DCCNVtl8V0KrmeVd/KE= +sigs.k8s.io/kustomize/kustomize/v5 v5.7.1 h1:sYJsarwy/SDJfjjLMUqwFDGPwzUtMOQ1i1Ed49+XSbw= +sigs.k8s.io/kustomize/kustomize/v5 v5.7.1/go.mod h1:+5/SrBcJ4agx1SJknGuR/c9thwRSKLxnKoI5BzXFaLU= +sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= +sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/secrets-store-csi-driver v1.5.5 h1:LJDpDL5TILhlP68nGvtGSlJFxSDgAD2m148NT0Ts7os= +sigs.k8s.io/secrets-store-csi-driver v1.5.5/go.mod h1:i2WqLicYH00hrTG3JAzICPMF4HL4KMEORlDt9UQoZLk= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/pkg/metrics/prometheus.go b/internal/pkg/metrics/prometheus.go index b95731c8..acaaa0dc 100644 --- a/internal/pkg/metrics/prometheus.go +++ b/internal/pkg/metrics/prometheus.go @@ -169,6 +169,7 @@ func (c *Collectors) RecordWorkloadsMatched(kind string, count int) { } func NewCollectors() Collectors { + // Existing metrics (preserved) reloaded := prometheus.NewCounterVec( prometheus.CounterOpts{ Namespace: "reloader", diff --git a/scripts/e2e-cluster-cleanup.sh b/scripts/e2e-cluster-cleanup.sh new file mode 100755 index 00000000..b5005299 --- /dev/null +++ b/scripts/e2e-cluster-cleanup.sh @@ -0,0 +1,283 @@ +#!/bin/bash +# Cleanup script for e2e test cluster +# Run this after e2e tests complete: ./scripts/e2e-cluster-cleanup.sh +# +# This removes: +# - Reloader test resources (namespaces, cluster roles, etc.) +# - Vault and its namespace +# - CSI Secrets Store Driver +# - Argo Rollouts +# +# Resources are removed in reverse dependency order. + +set -euo pipefail + +# ============================================================================= +# Configuration +# ============================================================================= + +ARGO_ROLLOUTS_VERSION="${ARGO_ROLLOUTS_VERSION:-v1.7.2}" +ARGO_ROLLOUTS_NAMESPACE="argo-rollouts" +CSI_DRIVER_VERSION="${CSI_DRIVER_VERSION:-1.5.5}" +CSI_NAMESPACE="kube-system" +VAULT_NAMESPACE="vault" + +# ============================================================================= +# Helper Functions +# ============================================================================= + +log_header() { + echo "" + echo "=== $1 ===" +} + +log_info() { + echo "$1" +} + +log_success() { + echo "✓ $1" +} + +log_warning() { + echo "⚠ $1" +} + +log_error() { + echo "✗ $1" >&2 +} + +check_command() { + if ! command -v "$1" &> /dev/null; then + log_error "$1 is not installed or not in PATH" + return 1 + fi + return 0 +} + +# Safe delete that ignores "not found" errors +safe_delete() { + kubectl delete "$@" --ignore-not-found 2>/dev/null || true +} + +# ============================================================================= +# Dependency Checks +# ============================================================================= + +check_dependencies() { + log_header "Checking Dependencies" + + if ! check_command kubectl; then + log_error "kubectl is required for cleanup" + exit 1 + fi + + log_success "Dependencies available" +} + +check_cluster_connectivity() { + log_header "Checking Cluster Connectivity" + + if ! kubectl cluster-info &> /dev/null; then + log_error "Cannot connect to Kubernetes cluster" + exit 1 + fi + + local context + context=$(kubectl config current-context) + log_success "Connected to cluster (context: $context)" +} + +# ============================================================================= +# Reloader Test Resources Cleanup +# ============================================================================= + +cleanup_reloader_resources() { + log_header "Cleaning Up Reloader Test Resources" + + # Delete test namespaces (created by test suites) + log_info "Deleting test namespaces..." + local namespaces + namespaces=$(kubectl get namespaces -o name 2>/dev/null | grep "reloader-" | cut -d/ -f2 || true) + if [[ -n "$namespaces" ]]; then + for ns in $namespaces; do + log_info " Deleting namespace: $ns" + kubectl delete namespace "$ns" --ignore-not-found --wait=false 2>/dev/null || true + done + else + log_info " No test namespaces found" + fi + + # Delete Reloader cluster-scoped resources + log_info "Deleting cluster roles..." + local clusterroles + clusterroles=$(kubectl get clusterrole -o name 2>/dev/null | grep "reloader-" | cut -d/ -f2 || true) + for cr in $clusterroles; do + log_info " Deleting ClusterRole: $cr" + safe_delete clusterrole "$cr" + done + + log_info "Deleting cluster role bindings..." + local clusterrolebindings + clusterrolebindings=$(kubectl get clusterrolebinding -o name 2>/dev/null | grep "reloader-" | cut -d/ -f2 || true) + for crb in $clusterrolebindings; do + log_info " Deleting ClusterRoleBinding: $crb" + safe_delete clusterrolebinding "$crb" + done + + log_success "Reloader test resources cleaned up" +} + +# ============================================================================= +# Vault Cleanup +# ============================================================================= + +cleanup_vault() { + log_header "Uninstalling Vault" + + # Check if Vault is installed + if ! kubectl get namespace "$VAULT_NAMESPACE" &> /dev/null; then + log_info "Vault namespace not found, skipping" + return 0 + fi + + # Uninstall via Helm if available + if command -v helm &> /dev/null; then + if helm list -n "$VAULT_NAMESPACE" 2>/dev/null | grep -q vault; then + log_info "Uninstalling Vault via Helm..." + helm uninstall vault -n "$VAULT_NAMESPACE" --wait --timeout 60s 2>/dev/null || true + fi + fi + + # Delete namespace + log_info "Deleting Vault namespace..." + safe_delete namespace "$VAULT_NAMESPACE" --timeout=60s + + log_success "Vault cleaned up" +} + +# ============================================================================= +# CSI Secrets Store Driver Cleanup +# ============================================================================= + +cleanup_csi_driver() { + log_header "Uninstalling CSI Secrets Store Driver" + + # Delete all SecretProviderClass resources first + log_info "Deleting SecretProviderClass resources..." + kubectl delete secretproviderclasses.secrets-store.csi.x-k8s.io \ + --all --all-namespaces --ignore-not-found --timeout=30s 2>/dev/null || true + + log_info "Deleting SecretProviderClassPodStatus resources..." + kubectl delete secretproviderclasspodstatuses.secrets-store.csi.x-k8s.io \ + --all --all-namespaces --ignore-not-found --timeout=30s 2>/dev/null || true + + # Uninstall via Helm if available + if command -v helm &> /dev/null; then + if helm list -n "$CSI_NAMESPACE" 2>/dev/null | grep -q csi-secrets-store; then + log_info "Uninstalling CSI Secrets Store Driver via Helm..." + helm uninstall csi-secrets-store -n "$CSI_NAMESPACE" --wait --timeout 60s 2>/dev/null || true + fi + else + # Fallback to kubectl delete + log_info "Deleting CSI Secrets Store Driver resources via kubectl..." + local csi_url="https://raw.githubusercontent.com/kubernetes-sigs/secrets-store-csi-driver/v${CSI_DRIVER_VERSION}/deploy/secrets-store-csi-driver.yaml" + kubectl delete -f "$csi_url" --ignore-not-found --timeout=60s 2>/dev/null || true + fi + + # Delete CRDs + log_info "Deleting CSI Secrets Store CRDs..." + local csi_crds="secretproviderclasses.secrets-store.csi.x-k8s.io secretproviderclasspodstatuses.secrets-store.csi.x-k8s.io" + for crd in $csi_crds; do + safe_delete crd "$crd" --timeout=30s + done + + log_success "CSI Secrets Store Driver cleaned up" +} + +# ============================================================================= +# Argo Rollouts Cleanup +# ============================================================================= + +cleanup_argo_rollouts() { + log_header "Uninstalling Argo Rollouts" + + # Check if Argo Rollouts is installed + if ! kubectl get namespace "$ARGO_ROLLOUTS_NAMESPACE" &> /dev/null; then + log_info "Argo Rollouts namespace not found, skipping" + return 0 + fi + + # Stop the controller first + log_info "Stopping Argo Rollouts controller..." + safe_delete deployment argo-rollouts -n "$ARGO_ROLLOUTS_NAMESPACE" --timeout=30s + + # Delete all Argo Rollouts custom resources to avoid finalizer issues + log_info "Deleting Argo Rollouts custom resources..." + local argo_resources="rollouts analysisruns analysistemplates experiments" + for res in $argo_resources; do + kubectl delete "${res}.argoproj.io" --all --all-namespaces --ignore-not-found --timeout=30s 2>/dev/null || true + done + + # Delete using the install manifest + log_info "Deleting Argo Rollouts installation..." + local argo_url="https://github.com/argoproj/argo-rollouts/releases/download/${ARGO_ROLLOUTS_VERSION}/install.yaml" + kubectl delete -f "$argo_url" --ignore-not-found --timeout=60s 2>/dev/null || true + + # Give resources time to be cleaned up + sleep 2 + + # Delete CRDs + log_info "Deleting Argo Rollouts CRDs..." + local argo_crds="rollouts.argoproj.io analysisruns.argoproj.io analysistemplates.argoproj.io clusteranalysistemplates.argoproj.io experiments.argoproj.io" + for crd in $argo_crds; do + safe_delete crd "$crd" --timeout=30s + done + + # Delete namespace + log_info "Deleting Argo Rollouts namespace..." + safe_delete namespace "$ARGO_ROLLOUTS_NAMESPACE" --timeout=30s + + # Delete cluster-scoped RBAC + log_info "Deleting Argo Rollouts cluster RBAC..." + safe_delete clusterrole argo-rollouts argo-rollouts-aggregate-to-admin argo-rollouts-aggregate-to-edit argo-rollouts-aggregate-to-view + safe_delete clusterrolebinding argo-rollouts + + log_success "Argo Rollouts cleaned up" +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + echo "=== E2E Cluster Cleanup ===" + + # Pre-flight checks + check_dependencies + check_cluster_connectivity + + # Cleanup in reverse dependency order + # 1. First cleanup test resources (they depend on everything else) + cleanup_reloader_resources + + # 2. Then Vault (depends on CSI driver) + cleanup_vault + + # 3. Then CSI driver + cleanup_csi_driver + + # 4. Finally Argo Rollouts (independent) + cleanup_argo_rollouts + + # Summary + log_header "E2E Cluster Cleanup Complete" + echo "" + echo "Removed components:" + echo " ✓ Reloader test namespaces and cluster resources" + echo " ✓ Vault" + echo " ✓ CSI Secrets Store Driver" + echo " ✓ Argo Rollouts" +} + +main "$@" diff --git a/scripts/e2e-cluster-setup.sh b/scripts/e2e-cluster-setup.sh new file mode 100755 index 00000000..20d1b819 --- /dev/null +++ b/scripts/e2e-cluster-setup.sh @@ -0,0 +1,351 @@ +#!/bin/bash +# Setup script for e2e test cluster +# Run this before running e2e tests: ./scripts/e2e-cluster-setup.sh +# +# This installs: +# - Argo Rollouts (for Rollout workload testing) +# - CSI Secrets Store Driver (for SecretProviderClass testing) +# - Vault with CSI Provider (as the secrets backend for CSI) +# +# All versions are pinned for reproducibility and can be overridden via environment variables. + +set -euo pipefail + +# ============================================================================= +# Configuration (all versions pinned for reproducibility) +# ============================================================================= + +# Argo Rollouts +ARGO_ROLLOUTS_VERSION="${ARGO_ROLLOUTS_VERSION:-v1.7.2}" +ARGO_ROLLOUTS_NAMESPACE="argo-rollouts" + +# CSI Secrets Store Driver +CSI_DRIVER_VERSION="${CSI_DRIVER_VERSION:-1.5.5}" +CSI_NAMESPACE="kube-system" + +# Vault (HashiCorp) +VAULT_CHART_VERSION="${VAULT_CHART_VERSION:-0.31.0}" +VAULT_VERSION="${VAULT_VERSION:-1.20.4}" +VAULT_CSI_PROVIDER_VERSION="${VAULT_CSI_PROVIDER_VERSION:-1.7.0}" +VAULT_NAMESPACE="vault" + +# ============================================================================= +# Helper Functions +# ============================================================================= + +log_header() { + echo "" + echo "=== $1 ===" +} + +log_info() { + echo "$1" +} + +log_success() { + echo "✓ $1" +} + +log_warning() { + echo "⚠ $1" +} + +log_error() { + echo "✗ $1" >&2 +} + +check_command() { + if ! command -v "$1" &> /dev/null; then + log_error "$1 is not installed or not in PATH" + return 1 + fi + return 0 +} + +wait_for_rollout() { + local resource_type="$1" + local resource_name="$2" + local namespace="$3" + local timeout="${4:-180s}" + + kubectl rollout status "$resource_type/$resource_name" -n "$namespace" --timeout="$timeout" +} + +wait_for_condition() { + local condition="$1" + local resource="$2" + local namespace="${3:-}" + local timeout="${4:-60s}" + + if [[ -n "$namespace" ]]; then + kubectl wait --for="condition=$condition" "$resource" -n "$namespace" --timeout="$timeout" + else + kubectl wait --for="condition=$condition" "$resource" --timeout="$timeout" + fi +} + +# ============================================================================= +# Dependency Checks +# ============================================================================= + +check_dependencies() { + log_header "Checking Dependencies" + + local missing_deps=() + + # Required: kubectl + if ! check_command kubectl; then + missing_deps+=("kubectl") + fi + + # Required: helm (for CSI driver and Vault installation) + if ! check_command helm; then + missing_deps+=("helm") + fi + + if [[ ${#missing_deps[@]} -gt 0 ]]; then + log_error "Missing required dependencies: ${missing_deps[*]}" + log_error "Please install the missing tools and try again." + exit 1 + fi + + log_success "All required dependencies are available" +} + +check_cluster_connectivity() { + log_header "Checking Cluster Connectivity" + + if ! kubectl cluster-info &> /dev/null; then + log_error "Cannot connect to Kubernetes cluster" + log_error "Please ensure your kubeconfig is correctly configured" + exit 1 + fi + + local context + context=$(kubectl config current-context) + log_success "Connected to cluster (context: $context)" +} + +# ============================================================================= +# Argo Rollouts Installation +# ============================================================================= + +install_argo_rollouts() { + log_header "Installing Argo Rollouts ${ARGO_ROLLOUTS_VERSION}" + + # Check if already installed + if kubectl get crd rollouts.argoproj.io &> /dev/null; then + if kubectl get deployment argo-rollouts -n "$ARGO_ROLLOUTS_NAMESPACE" &> /dev/null; then + log_success "Argo Rollouts is already installed" + return 0 + fi + log_info "Argo Rollouts CRD exists but controller not running, reinstalling..." + fi + + # Create namespace + kubectl create namespace "$ARGO_ROLLOUTS_NAMESPACE" 2>/dev/null || true + + # Install from official manifest + local argo_url="https://github.com/argoproj/argo-rollouts/releases/download/${ARGO_ROLLOUTS_VERSION}/install.yaml" + log_info "Applying manifest from: $argo_url" + kubectl apply -n "$ARGO_ROLLOUTS_NAMESPACE" -f "$argo_url" + + # Wait for deployment to be created + sleep 2 + + # Patch deployment to remove resource requirements (for Kind cluster compatibility) + log_info "Patching deployment for Kind compatibility..." + local patch_json='[{"op": "remove", "path": "/spec/template/spec/containers/0/resources"}]' + if ! kubectl patch deployment argo-rollouts -n "$ARGO_ROLLOUTS_NAMESPACE" --type=json -p "$patch_json" 2>/dev/null; then + patch_json='{"spec":{"template":{"spec":{"containers":[{"name":"argo-rollouts","resources":{"limits":null,"requests":null}}]}}}}' + kubectl patch deployment argo-rollouts -n "$ARGO_ROLLOUTS_NAMESPACE" --type=strategic -p "$patch_json" 2>/dev/null || true + fi + + # Wait for controller to be ready + log_info "Waiting for Argo Rollouts controller..." + wait_for_condition "available" "deployment/argo-rollouts" "$ARGO_ROLLOUTS_NAMESPACE" "180s" + wait_for_condition "established" "crd/rollouts.argoproj.io" "" "60s" + + log_success "Argo Rollouts ${ARGO_ROLLOUTS_VERSION} installed" +} + +# ============================================================================= +# CSI Secrets Store Driver Installation +# ============================================================================= + +install_csi_driver() { + log_header "Installing CSI Secrets Store Driver ${CSI_DRIVER_VERSION}" + + # Check if already installed + if kubectl get crd secretproviderclasses.secrets-store.csi.x-k8s.io &> /dev/null; then + if kubectl get daemonset -n "$CSI_NAMESPACE" -l app=secrets-store-csi-driver &> /dev/null 2>&1; then + log_success "CSI Secrets Store Driver is already installed" + return 0 + fi + log_info "CSI Driver CRD exists but DaemonSet not found, installing..." + fi + + # Add Helm repo + helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts 2>/dev/null || true + helm repo update secrets-store-csi-driver + + # Install via Helm with pinned version + log_info "Installing via Helm (version ${CSI_DRIVER_VERSION})..." + helm upgrade --install csi-secrets-store secrets-store-csi-driver/secrets-store-csi-driver \ + --namespace "$CSI_NAMESPACE" \ + --version "$CSI_DRIVER_VERSION" \ + --set syncSecret.enabled=true \ + --set enableSecretRotation=true \ + --set rotationPollInterval=2s \ + --wait \ + --timeout 180s + + # Wait for CRDs to be established + log_info "Waiting for CRDs to be established..." + wait_for_condition "established" "crd/secretproviderclasses.secrets-store.csi.x-k8s.io" "" "60s" + wait_for_condition "established" "crd/secretproviderclasspodstatuses.secrets-store.csi.x-k8s.io" "" "60s" + + # Wait for DaemonSet to be ready (try different names as they vary by installation method) + log_info "Waiting for CSI driver pods..." + kubectl rollout status daemonset/csi-secrets-store-secrets-store-csi-driver -n "$CSI_NAMESPACE" --timeout=180s 2>/dev/null || \ + kubectl rollout status daemonset/secrets-store-csi-driver -n "$CSI_NAMESPACE" --timeout=180s 2>/dev/null || \ + log_warning "Could not verify DaemonSet status (name may vary)" + + log_success "CSI Secrets Store Driver ${CSI_DRIVER_VERSION} installed" +} + +# ============================================================================= +# Vault Installation +# ============================================================================= + +install_vault() { + log_header "Installing Vault ${VAULT_VERSION} (Chart ${VAULT_CHART_VERSION})" + + # Check if already installed + if kubectl get pods -n "$VAULT_NAMESPACE" -l app.kubernetes.io/name=vault 2>/dev/null | grep -q Running; then + log_success "Vault is already installed and running" + return 0 + fi + + # Add Helm repo + helm repo add hashicorp https://helm.releases.hashicorp.com 2>/dev/null || true + helm repo update hashicorp + + # Install Vault in dev mode with CSI provider + # Dev mode: single server, in-memory storage, pre-unsealed, root token = "root" + log_info "Installing Vault via Helm..." + helm upgrade --install vault hashicorp/vault \ + --namespace "$VAULT_NAMESPACE" \ + --create-namespace \ + --version "$VAULT_CHART_VERSION" \ + --set "server.image.tag=${VAULT_VERSION}" \ + --set "server.dev.enabled=true" \ + --set "server.dev.devRootToken=root" \ + --set "server.resources.requests.memory=64Mi" \ + --set "server.resources.requests.cpu=50m" \ + --set "server.resources.limits.memory=128Mi" \ + --set "server.resources.limits.cpu=100m" \ + --set "injector.enabled=false" \ + --set "csi.enabled=true" \ + --set "csi.image.tag=${VAULT_CSI_PROVIDER_VERSION}" \ + --set "csi.resources.requests.memory=64Mi" \ + --set "csi.resources.requests.cpu=50m" \ + --set "csi.resources.limits.memory=128Mi" \ + --set "csi.resources.limits.cpu=100m" \ + --wait \ + --timeout 180s + + # Wait for pods to be ready + log_info "Waiting for Vault pod..." + kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=vault -n "$VAULT_NAMESPACE" --timeout=120s + + log_info "Waiting for Vault CSI provider..." + wait_for_rollout "daemonset" "vault-csi-provider" "$VAULT_NAMESPACE" "120s" + + log_success "Vault ${VAULT_VERSION} installed" +} + +configure_vault() { + log_header "Configuring Vault for Kubernetes Authentication" + + # Enable KV secrets engine (ignore error if already enabled - dev mode has it by default) + log_info "Enabling KV secrets engine..." + kubectl exec -n "$VAULT_NAMESPACE" vault-0 -- vault secrets enable -path=secret kv-v2 2>/dev/null || true + + # Create test secrets for e2e tests + log_info "Creating test secrets..." + kubectl exec -n "$VAULT_NAMESPACE" vault-0 -- vault kv put secret/test username="test-user" password="test-password" + kubectl exec -n "$VAULT_NAMESPACE" vault-0 -- vault kv put secret/app1 api_key="app1-api-key-v1" db_password="app1-db-pass-v1" + kubectl exec -n "$VAULT_NAMESPACE" vault-0 -- vault kv put secret/app2 api_key="app2-api-key-v1" db_password="app2-db-pass-v1" + kubectl exec -n "$VAULT_NAMESPACE" vault-0 -- vault kv put secret/rotation-test value="initial-value-v1" + + # Enable Kubernetes auth method + log_info "Enabling Kubernetes auth..." + kubectl exec -n "$VAULT_NAMESPACE" vault-0 -- vault auth enable kubernetes 2>/dev/null || true + + # Configure Kubernetes auth to use in-cluster config + log_info "Configuring Kubernetes auth..." + kubectl exec -n "$VAULT_NAMESPACE" vault-0 -- sh -c \ + 'vault write auth/kubernetes/config kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443"' + + # Create policy for reading test secrets + log_info "Creating Vault policy..." + kubectl exec -n "$VAULT_NAMESPACE" vault-0 -- sh -c 'vault policy write test-policy - <&2; } + +confirm() { + local msg="$1" + echo -en "${YELLOW}$msg [y/N]:${NC} " + read -r answer + [[ "$answer" =~ ^[Yy]$ ]] +} + +usage() { + cat < + +Automates the full Reloader release process. + +Arguments: + APP_VERSION Application version without 'v' prefix (e.g. 1.5.0, 1.5.0-alpha) + CHART_VERSION Helm chart version (e.g. 2.3.0, 2.3.0-rc.1) + +Prerequisites: + - gh CLI authenticated with repo access + - git configured with push access to $REPO + +Example: + $0 1.5.0 2.3.0 +EOF + exit 1 +} + +# --- Input validation --- +[[ $# -ne 2 ]] && usage + +APP_VERSION="$1" +CHART_VERSION="$2" + +# Strip 'v' prefix if provided +APP_VERSION="${APP_VERSION#v}" +CHART_VERSION="${CHART_VERSION#v}" + +# Validate semver format (with optional prerelease suffix e.g. 1.5.0-alpha, 1.5.0-rc.1) +SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+([-][a-zA-Z0-9.]+)?$' + +if ! [[ "$APP_VERSION" =~ $SEMVER_RE ]]; then + error "APP_VERSION '$APP_VERSION' is not valid semver (expected X.Y.Z or X.Y.Z-prerelease)" + exit 1 +fi + +if ! [[ "$CHART_VERSION" =~ $SEMVER_RE ]]; then + error "CHART_VERSION '$CHART_VERSION' is not valid semver (expected X.Y.Z or X.Y.Z-prerelease)" + exit 1 +fi + +# Check prerequisites +if ! command -v gh &> /dev/null; then + error "gh CLI is not installed. Install from https://cli.github.com/" + exit 1 +fi + +if ! gh auth status &> /dev/null; then + error "gh CLI is not authenticated. Run 'gh auth login' first." + exit 1 +fi + +RELEASE_BRANCH="release-v${APP_VERSION}" +TAG="v${APP_VERSION}" + +info "Release plan:" +info " App version: $APP_VERSION (tag: $TAG)" +info " Chart version: $CHART_VERSION" +info " Release branch: $RELEASE_BRANCH" +echo "" + +# ============================================================================= +# Phase 1: Create release branch +# ============================================================================= +info "Phase 1: Create release branch '$RELEASE_BRANCH' from master" + +if git ls-remote --heads origin "$RELEASE_BRANCH" | grep -q "$RELEASE_BRANCH"; then + warn "Branch '$RELEASE_BRANCH' already exists on remote." + if ! confirm "Continue using existing branch?"; then + error "Aborted." + exit 1 + fi +else + if ! confirm "Create and push branch '$RELEASE_BRANCH' from master?"; then + error "Aborted." + exit 1 + fi + git fetch origin master + git push origin origin/master:refs/heads/"$RELEASE_BRANCH" + info "Branch '$RELEASE_BRANCH' created and pushed." +fi +echo "" + +# ============================================================================= +# Phase 2: Trigger Init Release workflow and merge its PR +# ============================================================================= +info "Phase 2: Trigger Init Release workflow" + +if ! confirm "Trigger 'Init Release' workflow for branch '$RELEASE_BRANCH' with version '$APP_VERSION'?"; then + error "Aborted." + exit 1 +fi + +gh workflow run init-branch-release.yaml \ + --repo "$REPO" \ + -f TARGET_BRANCH="$RELEASE_BRANCH" \ + -f TARGET_VERSION="$APP_VERSION" + +info "Workflow triggered. Waiting for version bump PR to be created..." + +# Poll for the PR (created by the workflow targeting the release branch) +MAX_ATTEMPTS=30 +SLEEP_INTERVAL=10 +PR_NUMBER="" + +for i in $(seq 1 $MAX_ATTEMPTS); do + PR_NUMBER=$(gh pr list \ + --repo "$REPO" \ + --base "$RELEASE_BRANCH" \ + --search "Bump version to $APP_VERSION" \ + --json number \ + --jq '.[0].number // empty' 2>/dev/null || true) + + if [[ -n "$PR_NUMBER" ]]; then + info "Found PR #$PR_NUMBER" + break + fi + echo -n "." + sleep "$SLEEP_INTERVAL" +done + +if [[ -z "$PR_NUMBER" ]]; then + error "Timed out waiting for Init Release PR. Check workflow status at:" + error " https://github.com/$REPO/actions/workflows/init-branch-release.yaml" + exit 1 +fi + +info "PR: https://github.com/$REPO/pull/$PR_NUMBER" + +if ! confirm "Merge PR #$PR_NUMBER (version bump to $APP_VERSION)?"; then + error "Aborted. PR is still open: https://github.com/$REPO/pull/$PR_NUMBER" + exit 1 +fi + +gh pr merge "$PR_NUMBER" --repo "$REPO" --merge +info "PR #$PR_NUMBER merged." +echo "" + +# ============================================================================= +# Phase 3: Create GitHub release +# ============================================================================= +info "Phase 3: Create GitHub release '$TAG' targeting '$RELEASE_BRANCH'" +info "This will trigger the release workflow (Docker image builds, GoReleaser)." + +if ! confirm "Create GitHub release '$TAG'?"; then + error "Aborted." + exit 1 +fi + +gh release create "$TAG" \ + --repo "$REPO" \ + --target "$RELEASE_BRANCH" \ + --title "Release $TAG" \ + --generate-notes + +info "GitHub release created: https://github.com/$REPO/releases/tag/$TAG" +info "Release workflow will run in the background." +echo "" + +# ============================================================================= +# Phase 4: Bump Helm chart and create PR +# ============================================================================= +info "Phase 4: Bump Helm chart version to $CHART_VERSION (appVersion: v$APP_VERSION)" + +HELM_BRANCH="release-helm-chart-v${CHART_VERSION}" + +if ! confirm "Create branch '$HELM_BRANCH', bump chart files, and open PR with 'release/helm-chart' label?"; then + error "Aborted." + exit 1 +fi + +# Create branch from latest master +git fetch origin master +git checkout -b "$HELM_BRANCH" origin/master + +# Bump Chart.yaml: version and appVersion +CHART_FILE="deployments/kubernetes/chart/reloader/Chart.yaml" +sed -i "s/^version:.*/version: ${CHART_VERSION}/" "$CHART_FILE" +sed -i "s/^appVersion:.*/appVersion: v${APP_VERSION}/" "$CHART_FILE" + +# Bump values.yaml: image.tag +VALUES_FILE="deployments/kubernetes/chart/reloader/values.yaml" +sed -i "s/^\( tag:\).*/\1 v${APP_VERSION}/" "$VALUES_FILE" + +# Show changes for review +info "Changes:" +git diff + +git add "$CHART_FILE" "$VALUES_FILE" +git commit -m "Bump helm chart to ${CHART_VERSION} and appVersion to v${APP_VERSION}" +git push origin "$HELM_BRANCH" + +HELM_PR_URL=$(gh pr create \ + --repo "$REPO" \ + --base master \ + --head "$HELM_BRANCH" \ + --title "Bump Helm chart to ${CHART_VERSION} (appVersion v${APP_VERSION})" \ + --body "Bump Helm chart version to ${CHART_VERSION} and appVersion to v${APP_VERSION}." \ + --label "release/helm-chart") + +HELM_PR_NUMBER=$(echo "$HELM_PR_URL" | grep -o '[0-9]*$') +info "Helm chart PR created: $HELM_PR_URL" + +if ! confirm "Merge Helm chart PR #$HELM_PR_NUMBER?"; then + error "Aborted. PR is still open: $HELM_PR_URL" + exit 1 +fi + +gh pr merge "$HELM_PR_NUMBER" --repo "$REPO" --merge +info "Helm chart PR #$HELM_PR_NUMBER merged." + +# Return to previous branch +git checkout - + +echo "" +info "=============================================" +info "Release $TAG complete!" +info "=============================================" +info "" +info "Summary:" +info " - Release branch: $RELEASE_BRANCH" +info " - GitHub release: https://github.com/$REPO/releases/tag/$TAG" +info " - Helm chart: $CHART_VERSION (appVersion: v$APP_VERSION)" +info "" +info "The release workflow is running in the background." +info "Monitor at: https://github.com/$REPO/actions" diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 00000000..608ecf5b --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,124 @@ +# Reloader E2E Tests + +End-to-end tests verifying Reloader functionality in a real Kubernetes cluster. + +## Quick Start + +```bash +make e2e-setup # Create Kind cluster, install Argo/CSI/Vault +make e2e # Build image, run tests +make e2e-cleanup # Teardown +``` + +## Prerequisites + +- Go 1.26+ +- Docker or Podman +- [Kind](https://kind.sigs.k8s.io/) 0.20+ +- kubectl +- Helm 3.x + +## Running Tests + +```bash +# Run all tests +make e2e + +# Run specific suite +go tool ginkgo -v ./test/e2e/core/... + +# Run by pattern +go tool ginkgo -v --focus="ConfigMap" ./test/e2e/... + +# Run by label +go tool ginkgo -v --label-filter="csi" ./test/e2e/... +go tool ginkgo -v --label-filter="!argo && !openshift" ./test/e2e/... + +# Test a specific image +SKIP_BUILD=true RELOADER_IMAGE=ghcr.io/stakater/reloader:v1.2.0 make e2e +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|----------------------------------|-------------| +| `RELOADER_IMAGE` | `ghcr.io/stakater/reloader:test` | Image to test | +| `SKIP_BUILD` | `false` | Skip the container image build and Kind load steps; requires `RELOADER_IMAGE` to point to an already-loaded image | +| `KIND_CLUSTER` | `reloader-e2e` | Kind cluster name | +| `E2E_TIMEOUT` | `45m` | Test timeout | +| `GINKGO_PROCS` | `1` | Number of parallel Ginkgo worker processes | + +## Test Structure + +``` +test/e2e/ +├── core/ # Core reload functionality +├── annotations/ # Annotation behaviors (auto, exclude, search/match) +├── flags/ # CLI flag behaviors +├── advanced/ # Jobs, multi-container, regex patterns +├── csi/ # SecretProviderClass integration +├── argo/ # Argo Rollouts (requires CRDs) +└── utils/ # Shared test utilities and workload adapters +``` + +### Labels + +| Label | Description | +|-------|-------------| +| `csi` | Requires CSI driver and Vault | +| `argo` | Requires Argo Rollouts CRDs | +| `openshift` | Requires OpenShift cluster | + +## Writing Tests + +Use the workload adapter pattern for cross-workload tests: + +```go +DescribeTable("should reload when ConfigMap changes", func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s not available", workloadType)) + } + + // Create resources + _, err := utils.CreateConfigMap(ctx, kubeClient, ns, cmName, map[string]string{"key": "v1"}, nil) + Expect(err).NotTo(HaveOccurred()) + + err = adapter.Create(ctx, ns, name, utils.WorkloadConfig{ + ConfigMapName: cmName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildConfigMapReloadAnnotation(cmName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, ns, name) }) + + // Wait ready + Expect(adapter.WaitReady(ctx, ns, name, utils.WorkloadReadyTimeout)).To(Succeed()) + + // Trigger reload + Expect(utils.UpdateConfigMap(ctx, kubeClient, ns, cmName, map[string]string{"key": "v2"})).To(Succeed()) + + // Verify + reloaded, err := adapter.WaitReloaded(ctx, ns, name, utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue()) +}, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), +) +``` + +## Debugging + +```bash +# Reloader logs +kubectl logs -n -l app.kubernetes.io/name=reloader -f + +# Test resources +kubectl get deploy,ds,sts,cm,secret -n + +# CSI resources +kubectl get secretproviderclass,secretproviderclasspodstatus -A +``` diff --git a/test/e2e/advanced/advanced_suite_test.go b/test/e2e/advanced/advanced_suite_test.go new file mode 100644 index 00000000..bac2aaa2 --- /dev/null +++ b/test/e2e/advanced/advanced_suite_test.go @@ -0,0 +1,93 @@ +package advanced + +import ( + "context" + "encoding/json" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var ( + kubeClient kubernetes.Interface + csiClient csiclient.Interface + restConfig *rest.Config + testNamespace string + ctx context.Context + testEnv *utils.TestEnvironment +) + +func TestAdvanced(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Advanced E2E Suite") +} + +// SynchronizedBeforeSuite ensures only process 1 deploys Reloader. +// The namespace and release name are forwarded to all other processes so they +// share a single Reloader instance, avoiding resource exhaustion on Kind. +var _ = SynchronizedBeforeSuite( + // Process 1 only: create namespace, deploy Reloader. + func() []byte { + setupEnv, err := utils.SetupTestEnvironment(context.Background(), "reloader-advanced") + Expect(err).NotTo(HaveOccurred(), "Failed to setup test environment") + // Ensure the namespace is deleted even if DeployAndWait fails, so + // orphaned namespaces don't accumulate on long-lived clusters. + DeferCleanup(setupEnv.CleanupOnFailure) + + deployValues := map[string]string{ + "reloader.reloadStrategy": "annotations", + "reloader.watchGlobally": "false", + } + if utils.IsCSIDriverInstalled(context.Background(), setupEnv.CSIClient) { + deployValues["reloader.enableCSIIntegration"] = "true" + GinkgoWriter.Println("Deploying Reloader with CSI integration support") + } + + Expect(setupEnv.DeployAndWait(deployValues)).To(Succeed(), "Failed to deploy Reloader") + + data, err := json.Marshal(utils.SharedEnvData{ + Namespace: setupEnv.Namespace, + ReleaseName: setupEnv.ReleaseName, + }) + Expect(err).NotTo(HaveOccurred()) + return data + }, + // All processes (including #1): connect to the shared environment. + func(data []byte) { + var shared utils.SharedEnvData + Expect(json.Unmarshal(data, &shared)).To(Succeed()) + + var err error + testEnv, err = utils.SetupSharedTestEnvironment(context.Background(), shared.Namespace, shared.ReleaseName) + Expect(err).NotTo(HaveOccurred(), "Failed to setup shared test environment") + + kubeClient = testEnv.KubeClient + csiClient = testEnv.CSIClient + restConfig = testEnv.RestConfig + testNamespace = testEnv.Namespace + ctx = testEnv.Ctx + }, +) + +var _ = SynchronizedAfterSuite( + // All processes: cancel the per-process context. + func() { + if testEnv != nil { + testEnv.Cancel() + } + }, + // Process 1 only (runs last): undeploy Reloader and delete namespace. + func() { + if testEnv != nil { + err := testEnv.Cleanup() + Expect(err).NotTo(HaveOccurred(), "Failed to cleanup test environment") + } + GinkgoWriter.Println("Advanced E2E Suite cleanup complete") + }, +) diff --git a/test/e2e/advanced/job_reload_test.go b/test/e2e/advanced/job_reload_test.go new file mode 100644 index 00000000..a54136ab --- /dev/null +++ b/test/e2e/advanced/job_reload_test.go @@ -0,0 +1,248 @@ +package advanced + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Job Workload Recreation Tests", func() { + var ( + jobName string + configMapName string + secretName string + spcName string + vaultSecretPath string + jobAdapter *utils.JobAdapter + ) + + BeforeEach(func() { + jobName = utils.RandName("job") + configMapName = utils.RandName("cm") + secretName = utils.RandName("secret") + spcName = utils.RandName("spc") + vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("vault")) + jobAdapter = utils.NewJobAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteJob(ctx, kubeClient, testNamespace, jobName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName) + _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) + _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) + }) + + Context("Job with ConfigMap reference", func() { + It("should recreate Job when referenced ConfigMap changes", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"JOB_CONFIG": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Job with ConfigMap envFrom") + job, err := utils.CreateJob(ctx, kubeClient, testNamespace, jobName, + utils.WithJobConfigMapEnvFrom(configMapName), + utils.WithJobAnnotations(utils.BuildConfigMapReloadAnnotation(configMapName))) + Expect(err).NotTo(HaveOccurred()) + originalUID := string(job.UID) + + By("Waiting for Job to be ready") + err = jobAdapter.WaitReady(ctx, testNamespace, jobName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"JOB_CONFIG": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Job to be recreated (new UID)") + _, recreated, err := jobAdapter.WaitRecreated(ctx, testNamespace, jobName, originalUID, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(recreated).To(BeTrue(), "Job should be recreated with new UID when ConfigMap changes") + }) + }) + + Context("Job with Secret reference", func() { + It("should recreate Job when referenced Secret changes", func() { + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"JOB_SECRET": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Job with Secret envFrom") + job, err := utils.CreateJob(ctx, kubeClient, testNamespace, jobName, utils.WithJobSecretEnvFrom(secretName), + utils.WithJobAnnotations(utils.BuildSecretReloadAnnotation(secretName))) + Expect(err).NotTo(HaveOccurred()) + originalUID := string(job.UID) + + By("Waiting for Job to be ready") + err = jobAdapter.WaitReady(ctx, testNamespace, jobName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"JOB_SECRET": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Job to be recreated (new UID)") + _, recreated, err := jobAdapter.WaitRecreated(ctx, testNamespace, jobName, originalUID, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(recreated).To(BeTrue(), "Job should be recreated with new UID when Secret changes") + }) + }) + + Context("Job with auto annotation", func() { + It("should recreate Job with auto=true when ConfigMap changes", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"AUTO_CONFIG": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Job with auto annotation") + job, err := utils.CreateJob(ctx, kubeClient, testNamespace, jobName, + utils.WithJobConfigMapEnvFrom(configMapName), + utils.WithJobAnnotations(utils.BuildAutoTrueAnnotation())) + Expect(err).NotTo(HaveOccurred()) + originalUID := string(job.UID) + + By("Waiting for Job to be ready") + err = jobAdapter.WaitReady(ctx, testNamespace, jobName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"AUTO_CONFIG": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Job to be recreated (new UID)") + _, recreated, err := jobAdapter.WaitRecreated(ctx, testNamespace, jobName, originalUID, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(recreated).To(BeTrue(), "Job with auto=true should be recreated when ConfigMap changes") + }) + }) + + Context("Job with valueFrom ConfigMap reference", func() { + It("should recreate Job when ConfigMap referenced via valueFrom changes", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"config_key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Job with valueFrom.configMapKeyRef") + job, err := utils.CreateJob(ctx, kubeClient, testNamespace, jobName, + utils.WithJobConfigMapKeyRef(configMapName, "config_key", "MY_CONFIG"), + utils.WithJobAnnotations(utils.BuildConfigMapReloadAnnotation(configMapName))) + Expect(err).NotTo(HaveOccurred()) + originalUID := string(job.UID) + + By("Waiting for Job to be ready") + err = jobAdapter.WaitReady(ctx, testNamespace, jobName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"config_key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Job to be recreated (new UID)") + _, recreated, err := jobAdapter.WaitRecreated(ctx, testNamespace, jobName, originalUID, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(recreated).To(BeTrue(), + "Job with valueFrom.configMapKeyRef should be recreated when ConfigMap changes") + }) + }) + + Context("Job with valueFrom Secret reference", func() { + It("should recreate Job when Secret referenced via valueFrom changes", func() { + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"secret_key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Job with valueFrom.secretKeyRef") + job, err := utils.CreateJob(ctx, kubeClient, testNamespace, jobName, + utils.WithJobSecretKeyRef(secretName, "secret_key", "MY_SECRET"), + utils.WithJobAnnotations(utils.BuildSecretReloadAnnotation(secretName))) + Expect(err).NotTo(HaveOccurred()) + originalUID := string(job.UID) + + By("Waiting for Job to be ready") + err = jobAdapter.WaitReady(ctx, testNamespace, jobName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"secret_key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Job to be recreated (new UID)") + _, recreated, err := jobAdapter.WaitRecreated(ctx, testNamespace, jobName, originalUID, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(recreated).To(BeTrue(), "Job with valueFrom.secretKeyRef should be recreated when Secret changes") + }) + }) + + Context("Job with SecretProviderClass reference", Label("csi"), func() { + BeforeEach(func() { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed - skipping CSI test") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed - skipping CSI test") + } + }) + + It("should recreate Job when Vault secret changes", func() { + By("Creating a secret in Vault") + err := utils.CreateVaultSecret( + ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret( + ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "api_key", + ) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Job with CSI volume and SPC reload annotation") + job, err := utils.CreateJob(ctx, kubeClient, testNamespace, jobName, + utils.WithJobCommand("sleep 300"), + utils.WithJobCSIVolume(spcName), + utils.WithJobAnnotations(utils.BuildSecretProviderClassReloadAnnotation(spcName))) + Expect(err).NotTo(HaveOccurred()) + originalUID := string(job.UID) + + By("Waiting for Job to be ready") + err = jobAdapter.WaitReady(ctx, testNamespace, jobName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForSPC( + ctx, csiClient, testNamespace, spcName, utils.WorkloadReadyTimeout, + ) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Printf("Found SPCPS: %s\n", spcpsName) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Printf("Initial SPCPS version: %s\n", initialVersion) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret( + ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Println("CSI driver synced new secret version") + + By("Waiting for Job to be recreated (new UID)") + _, recreated, err := jobAdapter.WaitRecreated(ctx, testNamespace, jobName, originalUID, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(recreated).To(BeTrue(), "Job should be recreated with new UID when Vault secret changes") + }) + }) +}) diff --git a/test/e2e/advanced/multi_container_test.go b/test/e2e/advanced/multi_container_test.go new file mode 100644 index 00000000..98ed6391 --- /dev/null +++ b/test/e2e/advanced/multi_container_test.go @@ -0,0 +1,219 @@ +package advanced + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Multi-Container Tests", Serial, func() { + var ( + deploymentName string + configMapName string + configMapName2 string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + configMapName2 = utils.RandName("cm2") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName2) + }) + + Context("Multiple containers same ConfigMap", func() { + It("should reload when ConfigMap used by multiple containers changes", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"shared-key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with multiple containers using the same ConfigMap") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithMultipleContainers(2), + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"shared-key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment with multiple containers should be reloaded") + }) + }) + + Context("Multiple containers different ConfigMaps", func() { + It("should reload when any container's ConfigMap changes", func() { + By("Creating two ConfigMaps") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key1": "initial1"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName2, + map[string]string{"key2": "initial2"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with multiple containers using different ConfigMaps") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithMultipleContainersAndEnv(configMapName, configMapName2), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the first ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key1": "updated1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should be reloaded when first container's ConfigMap changes") + }) + }) + + Context("Init container with CSI volume", Label("csi"), func() { + var ( + spcName string + vaultSecretPath string + ) + + BeforeEach(func() { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + spcName = utils.RandName("spc") + vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("test")) + }) + + AfterEach(func() { + if spcName != "" { + _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) + } + if vaultSecretPath != "" { + _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) + } + }) + + It("should reload when SecretProviderClassPodStatus used by init container changes", func() { + By("Creating a Vault secret") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ + "api_key": "initial-init-value", + }) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with init container using CSI volume") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithInitContainerCSIVolume(spcName), + utils.WithAnnotations(utils.BuildSecretProviderClassReloadAnnotation(spcName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ + "api_key": "updated-init-value", + }) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync (SPCPS version change)") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment with init container using CSI volume should be reloaded") + }) + + It("should reload with auto annotation when init container CSI volume changes", func() { + By("Creating a Vault secret") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ + "api_key": "initial-init-auto-value", + }) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with init container using CSI volume and auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithInitContainerCSIVolume(spcName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ + "api_key": "updated-init-auto-value", + }) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync (SPCPS version change)") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment with init container CSI volume and auto=true should be reloaded") + }) + }) +}) diff --git a/test/e2e/advanced/regex_test.go b/test/e2e/advanced/regex_test.go new file mode 100644 index 00000000..989bf0ab --- /dev/null +++ b/test/e2e/advanced/regex_test.go @@ -0,0 +1,134 @@ +package advanced + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Regex Pattern Tests", func() { + var ( + deploymentName string + matchingCM string + nonMatchingCM string + matchingSecret string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + matchingCM = "app-config-" + utils.RandName("cm") + nonMatchingCM = "other-" + utils.RandName("cm") + matchingSecret = "app-secret-" + utils.RandName("secret") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, matchingCM) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, nonMatchingCM) + _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, matchingSecret) + }) + + Context("ConfigMap regex pattern", func() { + It("should reload when ConfigMap matching pattern changes", func() { + By("Creating a ConfigMap matching the pattern") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, matchingCM, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with ConfigMap pattern annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(matchingCM), + utils.WithAnnotations(map[string]string{ + utils.AnnotationConfigMapReload: "app-config-.*", + }), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the matching ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, matchingCM, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should be reloaded when matching ConfigMap changes") + }) + + It("should NOT reload when ConfigMap NOT matching pattern changes", func() { + By("Creating ConfigMaps - one matching, one not") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, matchingCM, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, nonMatchingCM, + map[string]string{"other": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with ConfigMap pattern annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(matchingCM), + utils.WithAnnotations(map[string]string{ + utils.AnnotationConfigMapReload: "app-config-.*", + }), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the non-matching ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, nonMatchingCM, map[string]string{"other": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (pattern mismatch)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when non-matching ConfigMap changes") + }) + }) + + Context("Secret regex pattern", func() { + It("should reload when Secret matching pattern changes", func() { + By("Creating a Secret matching the pattern") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, matchingSecret, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with Secret pattern annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithSecretEnvFrom(matchingSecret), + utils.WithAnnotations(map[string]string{ + utils.AnnotationSecretReload: "app-secret-.*", + }), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the matching Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, matchingSecret, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should be reloaded when matching Secret changes") + }) + }) +}) diff --git a/test/e2e/annotations/annotations_suite_test.go b/test/e2e/annotations/annotations_suite_test.go new file mode 100644 index 00000000..8c9bc33e --- /dev/null +++ b/test/e2e/annotations/annotations_suite_test.go @@ -0,0 +1,108 @@ +package annotations + +import ( + "context" + "encoding/json" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var ( + kubeClient kubernetes.Interface + csiClient csiclient.Interface + restConfig *rest.Config + testNamespace string + ctx context.Context + testEnv *utils.TestEnvironment + registry *utils.AdapterRegistry +) + +func TestAnnotations(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Annotations Strategy E2E Suite") +} + +// SynchronizedBeforeSuite ensures only process 1 deploys Reloader. +// The namespace and release name are forwarded to all other processes so they +// share a single Reloader instance, avoiding resource exhaustion on Kind. +var _ = SynchronizedBeforeSuite( + // Process 1 only: create namespace, deploy Reloader. + func() []byte { + setupEnv, err := utils.SetupTestEnvironment(context.Background(), "reloader-annotations-test") + Expect(err).NotTo(HaveOccurred(), "Failed to setup test environment") + // Ensure the namespace is deleted even if DeployAndWait fails, so + // orphaned namespaces don't accumulate on long-lived clusters. + DeferCleanup(setupEnv.CleanupOnFailure) + + deployValues := map[string]string{ + "reloader.reloadStrategy": "annotations", + "reloader.watchGlobally": "false", + } + if utils.IsCSIDriverInstalled(context.Background(), setupEnv.CSIClient) { + deployValues["reloader.enableCSIIntegration"] = "true" + GinkgoWriter.Println("Deploying Reloader with CSI integration support") + } + + Expect(setupEnv.DeployAndWait(deployValues)).To(Succeed(), "Failed to deploy Reloader") + + data, err := json.Marshal(utils.SharedEnvData{ + Namespace: setupEnv.Namespace, + ReleaseName: setupEnv.ReleaseName, + }) + Expect(err).NotTo(HaveOccurred()) + return data + }, + // All processes (including #1): connect to shared environment and build adapter registry. + func(data []byte) { + var shared utils.SharedEnvData + Expect(json.Unmarshal(data, &shared)).To(Succeed()) + + var err error + testEnv, err = utils.SetupSharedTestEnvironment(context.Background(), shared.Namespace, shared.ReleaseName) + Expect(err).NotTo(HaveOccurred(), "Failed to setup shared test environment") + + kubeClient = testEnv.KubeClient + csiClient = testEnv.CSIClient + restConfig = testEnv.RestConfig + testNamespace = testEnv.Namespace + ctx = testEnv.Ctx + + registry = utils.NewAdapterRegistry(kubeClient) + if utils.IsArgoRolloutsInstalled(ctx, testEnv.RolloutsClient) { + GinkgoWriter.Println("Argo Rollouts detected, registering ArgoRolloutAdapter") + registry.RegisterAdapter(utils.NewArgoRolloutAdapter(testEnv.RolloutsClient)) + } else { + GinkgoWriter.Println("Argo Rollouts not detected, skipping ArgoRolloutAdapter registration") + } + if utils.HasDeploymentConfigSupport(testEnv.DiscoveryClient) && testEnv.OpenShiftClient != nil { + GinkgoWriter.Println("OpenShift detected, registering DeploymentConfigAdapter") + registry.RegisterAdapter(utils.NewDeploymentConfigAdapter(testEnv.OpenShiftClient)) + } else { + GinkgoWriter.Println("OpenShift not detected, skipping DeploymentConfigAdapter registration") + } + }, +) + +var _ = SynchronizedAfterSuite( + // All processes: cancel the per-process context. + func() { + if testEnv != nil { + testEnv.Cancel() + } + }, + // Process 1 only (runs last): undeploy Reloader and delete namespace. + func() { + if testEnv != nil { + err := testEnv.Cleanup() + Expect(err).NotTo(HaveOccurred(), "Failed to cleanup test environment") + } + GinkgoWriter.Println("Annotations E2E Suite cleanup complete") + }, +) diff --git a/test/e2e/annotations/auto_reload_test.go b/test/e2e/annotations/auto_reload_test.go new file mode 100644 index 00000000..c407fa39 --- /dev/null +++ b/test/e2e/annotations/auto_reload_test.go @@ -0,0 +1,408 @@ +package annotations + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Auto Reload Annotation Tests", func() { + var ( + deploymentName string + configMapName string + secretName string + spcName string + vaultSecretPath string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + secretName = utils.RandName("secret") + spcName = utils.RandName("spc") + vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("test")) + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName) + if csiClient != nil { + _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) + } + _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) + }) + + Context("with reloader.stakater.com/auto=true annotation", func() { + It("should reload Deployment when any referenced ConfigMap changes", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment with auto=true should have been reloaded") + }) + + It("should reload Deployment when any referenced Secret changes", func() { + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithSecretEnvFrom(secretName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret data") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment with auto=true should have been reloaded for Secret change") + }) + + It("should reload Deployment when either ConfigMap or Secret changes", func() { + By("Creating a ConfigMap and Secret") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"config": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"secret": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true annotation referencing both") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithSecretEnvFrom(secretName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"config": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment with auto=true should have been reloaded for ConfigMap change") + }) + }) + + // Note: auto=false test is now in core/workloads_test.go as a DescribeTable for all workload types + + Context("with configmap.reloader.stakater.com/auto=true annotation", func() { + It("should reload Deployment only when ConfigMap changes, not Secret", func() { + By("Creating a ConfigMap and Secret") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"config": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"secret": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with configmap auto=true annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithSecretEnvFrom(secretName), + utils.WithAnnotations(utils.BuildConfigMapAutoAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"config": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded for ConfigMap change") + }) + }) + + Context("with secret.reloader.stakater.com/auto=true annotation", func() { + It("should reload Deployment only when Secret changes, not ConfigMap", func() { + By("Creating a ConfigMap and Secret") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"config": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"secret": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with secret auto=true annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithSecretEnvFrom(secretName), + utils.WithAnnotations(utils.BuildSecretAutoAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"secret": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded for Secret change") + }) + }) + + Context("with secretproviderclass.reloader.stakater.com/auto=true annotation", Label("csi"), func() { + BeforeEach(func() { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + }) + + It("should reload Deployment when SecretProviderClassPodStatus changes", func() { + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with secretproviderclass auto=true annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithCSIVolume(spcName), + utils.WithAnnotations(utils.BuildSecretProviderClassAutoAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Printf("Found SPCPS: %s\n", spcpsName) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Printf("Initial SPCPS version: %s\n", initialVersion) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Println("CSI driver synced new secret version") + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded for Vault secret change") + }) + + It("should NOT reload Deployment when ConfigMap changes (only SPC auto enabled)", func() { + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating a ConfigMap") + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with CSI volume AND ConfigMap, but only SPC auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithCSIVolume(spcName), + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildSecretProviderClassAutoAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap (should NOT trigger reload with SPC auto only)") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded for ConfigMap change") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment with SPC auto only should NOT have been reloaded for ConfigMap change") + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret (should trigger reload)") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded for SPC change") + reloaded, err = adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded for Vault secret change") + }) + + It("should reload when using combined auto=true annotation for SPC", func() { + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with CSI volume and general auto=true annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithCSIVolume(spcName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment with auto=true should have been reloaded for Vault secret change") + }) + }) + + Context("with auto annotation and explicit reload annotation together", func() { + It("should reload when auto-detected resource changes", func() { + configMapName2 := utils.RandName("cm2") + defer func() { _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName2) }() + + By("Creating two ConfigMaps") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key1": "value1"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName2, + map[string]string{"key2": "value2"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true and explicit reload for first ConfigMap") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithConfigMapEnvFrom(configMapName2), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildConfigMapReloadAnnotation(configMapName), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the second ConfigMap (auto-detected)") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName2, map[string]string{"key2": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded for auto-detected ConfigMap change") + }) + }) +}) diff --git a/test/e2e/annotations/combination_test.go b/test/e2e/annotations/combination_test.go new file mode 100644 index 00000000..e7f02efa --- /dev/null +++ b/test/e2e/annotations/combination_test.go @@ -0,0 +1,346 @@ +package annotations + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Combination Annotation Tests", func() { + var ( + deploymentName string + configMapName string + configMapName2 string + secretName string + secretName2 string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + configMapName2 = utils.RandName("cm2") + secretName = utils.RandName("secret") + secretName2 = utils.RandName("secret2") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName2) + _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName) + _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName2) + }) + + Context("auto=true with explicit reload annotations", func() { + It("should reload when both auto-detected and explicitly listed ConfigMaps change", func() { + By("Creating two ConfigMaps") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName2, + map[string]string{"extra": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true AND explicit reload annotation for extra ConfigMap") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildConfigMapReloadAnnotation(configMapName2), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the auto-detected ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when auto-detected ConfigMap changes") + }) + + It("should reload when explicitly listed ConfigMap changes with auto=true", func() { + By("Creating two ConfigMaps") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName2, + map[string]string{"extra": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true AND explicit reload annotation for extra ConfigMap") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildConfigMapReloadAnnotation(configMapName2), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the explicitly listed ConfigMap (not mounted)") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName2, map[string]string{"extra": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when explicitly listed ConfigMap changes") + }) + + It("should reload when Secret changes with auto=true and explicit Secret annotation", func() { + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName2, + map[string]string{"api-key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true AND explicit reload annotation for extra Secret") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithSecretEnvFrom(secretName), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildSecretReloadAnnotation(secretName2), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the explicitly listed Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName2, map[string]string{"api-key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when explicitly listed Secret changes") + }) + }) + + Context("auto=true with exclude annotations", func() { + It("should NOT reload when excluded ConfigMap changes", func() { + By("Creating two ConfigMaps") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName2, + map[string]string{"excluded": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true AND exclude for second ConfigMap") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithConfigMapEnvFrom(configMapName2), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildConfigMapExcludeAnnotation(configMapName2), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the excluded ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName2, map[string]string{"excluded": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (negative test)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when excluded ConfigMap changes") + }) + + It("should reload when non-excluded ConfigMap changes", func() { + By("Creating two ConfigMaps") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName2, + map[string]string{"excluded": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true AND exclude for second ConfigMap") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithConfigMapEnvFrom(configMapName2), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildConfigMapExcludeAnnotation(configMapName2), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the non-excluded ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when non-excluded ConfigMap changes") + }) + + It("should NOT reload when excluded Secret changes", func() { + By("Creating two Secrets") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName2, + map[string]string{"excluded": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true AND exclude for second Secret") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithSecretEnvFrom(secretName), + utils.WithSecretEnvFrom(secretName2), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildSecretExcludeAnnotation(secretName2), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the excluded Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName2, map[string]string{"excluded": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (negative test)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when excluded Secret changes") + }) + }) + + Context("multiple explicit references", func() { + It("should reload when any of multiple explicitly listed ConfigMaps change", func() { + By("Creating multiple ConfigMaps") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key1": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName2, + map[string]string{"key2": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with multiple ConfigMaps in reload annotation (comma-separated)") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithAnnotations(utils.BuildConfigMapReloadAnnotation(configMapName, configMapName2)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the second ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName2, map[string]string{"key2": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when any of the listed ConfigMaps changes") + }) + + It("should reload when any of multiple explicitly listed Secrets change", func() { + By("Creating multiple Secrets") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"key1": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName2, + map[string]string{"key2": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with multiple Secrets in reload annotation (comma-separated)") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithAnnotations(utils.BuildSecretReloadAnnotation(secretName, secretName2)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the first Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"key1": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when any of the listed Secrets changes") + }) + + It("should reload when both ConfigMap and Secret annotations are present", func() { + By("Creating a ConfigMap and a Secret") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with both ConfigMap and Secret reload annotations") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildConfigMapReloadAnnotation(configMapName), + utils.BuildSecretReloadAnnotation(secretName), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when Secret changes with both annotations present") + }) + }) +}) diff --git a/test/e2e/annotations/exclude_test.go b/test/e2e/annotations/exclude_test.go new file mode 100644 index 00000000..73e0e8f0 --- /dev/null +++ b/test/e2e/annotations/exclude_test.go @@ -0,0 +1,385 @@ +package annotations + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Exclude Annotation Tests", func() { + var ( + deploymentName string + configMapName string + configMapName2 string + secretName string + secretName2 string + workloadName string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + configMapName2 = utils.RandName("cm2") + secretName = utils.RandName("secret") + secretName2 = utils.RandName("secret2") + workloadName = utils.RandName("workload") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName2) + _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName) + _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName2) + }) + + Context("ConfigMap exclude annotation", func() { + It("should NOT reload when excluded ConfigMap changes", func() { + By("Creating two ConfigMaps") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName2, + map[string]string{"key2": "initial2"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true and configmaps.exclude annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithConfigMapEnvFrom(configMapName2), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildConfigMapExcludeAnnotation(configMapName), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the excluded ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (excluded ConfigMap)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when excluded ConfigMap changes") + }) + + It("should reload when non-excluded ConfigMap changes", func() { + By("Creating two ConfigMaps") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName2, + map[string]string{"key2": "initial2"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true and configmaps.exclude annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithConfigMapEnvFrom(configMapName2), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildConfigMapExcludeAnnotation(configMapName), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the non-excluded ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName2, map[string]string{"key2": "updated2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when non-excluded ConfigMap changes") + }) + }) + + Context("Secret exclude annotation", func() { + It("should NOT reload when excluded Secret changes", func() { + By("Creating two Secrets") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName2, + map[string]string{"password2": "initial2"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true and secrets.exclude annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithSecretEnvFrom(secretName), + utils.WithSecretEnvFrom(secretName2), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildSecretExcludeAnnotation(secretName), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the excluded Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (excluded Secret)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when excluded Secret changes") + }) + + It("should reload when non-excluded Secret changes", func() { + By("Creating two Secrets") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName2, + map[string]string{"password2": "initial2"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true and secrets.exclude annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithSecretEnvFrom(secretName), + utils.WithSecretEnvFrom(secretName2), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildSecretExcludeAnnotation(secretName), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the non-excluded Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName2, map[string]string{"password2": "updated2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when non-excluded Secret changes") + }) + }) + + // TODO: Reloader currently only reads exclude annotations from workload metadata, not pod template. + // This test documents the expected behavior but needs Reloader code changes to pass. + Context("Exclude annotation on pod template", func() { + PDescribeTable("should NOT reload when exclude annotation is on pod template only", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating two ConfigMaps") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName2, + map[string]string{"key2": "initial2"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with auto=true and exclude annotation on pod template ONLY") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + PodTemplateAnnotations: utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildConfigMapExcludeAnnotation(configMapName), + ), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the excluded ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying workload was NOT reloaded (excluded ConfigMap)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "%s should NOT reload with exclude on pod template", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + }) + + Context("SecretProviderClass exclude annotation", Label("csi"), func() { + var ( + spcName string + spcName2 string + vaultSecretPath string + vaultSecretPath2 string + ) + + BeforeEach(func() { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + spcName = utils.RandName("spc") + spcName2 = utils.RandName("spc2") + vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("test")) + vaultSecretPath2 = fmt.Sprintf("secret/%s", utils.RandName("test2")) + }) + + AfterEach(func() { + _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) + _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName2) + _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) + _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath2) + }) + + It("should NOT reload when excluded SecretProviderClassPodStatus changes", func() { + By("Creating Vault secret for the excluded SPC") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ + "api_key": "initial-excluded-value", + }) + Expect(err).NotTo(HaveOccurred()) + + By("Creating SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true and secretproviderclasses.exclude annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithCSIVolume(spcName), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildSecretProviderClassExcludeAnnotation(spcName), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret for excluded SPC") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ + "api_key": "updated-excluded-value", + }) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync (SPCPS version change)") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (excluded SPC)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when excluded SecretProviderClassPodStatus changes") + }) + + It("should reload when non-excluded SecretProviderClassPodStatus changes", func() { + By("Creating two Vault secrets") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{ + "api_key": "initial-excluded-value", + }) + Expect(err).NotTo(HaveOccurred()) + + err = utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath2, map[string]string{ + "api_key": "initial-nonexcluded-value", + }) + Expect(err).NotTo(HaveOccurred()) + + By("Creating two SecretProviderClasses") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName2, vaultSecretPath2, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=true and secretproviderclasses.exclude for first SPC only") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithCSIVolume(spcName), + utils.WithCSIVolume(spcName2), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildAutoTrueAnnotation(), + utils.BuildSecretProviderClassExcludeAnnotation(spcName), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS for non-excluded SPC") + + spcpsName2, err := utils.FindSPCPSForSPC(ctx, csiClient, testNamespace, spcName2, 30*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Getting initial SPCPS version for non-excluded SPC") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName2) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret for non-excluded SPC") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath2, map[string]string{ + "api_key": "updated-nonexcluded-value", + }) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync (SPCPS version change)") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName2, initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when non-excluded SecretProviderClassPodStatus changes") + }) + }) +}) diff --git a/test/e2e/annotations/pause_period_test.go b/test/e2e/annotations/pause_period_test.go new file mode 100644 index 00000000..869aed1d --- /dev/null +++ b/test/e2e/annotations/pause_period_test.go @@ -0,0 +1,143 @@ +package annotations + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Pause Period Tests", func() { + var ( + deploymentName string + configMapName string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + }) + + Context("with pause-period annotation", func() { + It("should pause Deployment after reload", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with pause-period annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.MergeAnnotations( + utils.BuildConfigMapReloadAnnotation(configMapName), + utils.BuildPausePeriodAnnotation("10s"), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded") + + By("Verifying Deployment has paused-at annotation") + paused, err := adapter.WaitPaused(ctx, testNamespace, deploymentName, + utils.AnnotationDeploymentPausedAt, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(paused).To(BeTrue(), "Deployment should have paused-at annotation after reload") + }) + + It("should NOT pause Deployment without pause-period annotation", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment WITHOUT pause-period annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildConfigMapReloadAnnotation(configMapName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded") + + By("Verifying Deployment does NOT have paused-at annotation") + time.Sleep(utils.NegativeTestWait) + paused, err := adapter.WaitPaused(ctx, testNamespace, deploymentName, + utils.AnnotationDeploymentPausedAt, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(paused).To(BeFalse(), "Deployment should NOT have paused-at annotation without pause-period") + }) + + // FUTURE: Reloader currently only reads pause-period from deployment metadata, not pod template. + // This test is pending (skipped) and documents the expected future behavior. + // Requires Reloader code changes to support reading pause-period from pod template annotations. + PIt("should pause Deployment when pause-period annotation is on pod template", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with pause-period annotation on pod template ONLY") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithPodTemplateAnnotations(utils.MergeAnnotations( + utils.BuildConfigMapReloadAnnotation(configMapName), + utils.BuildPausePeriodAnnotation("10s"), + )), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded") + + By("Verifying Deployment has paused-at annotation") + paused, err := adapter.WaitPaused(ctx, testNamespace, deploymentName, + utils.AnnotationDeploymentPausedAt, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(paused).To(BeTrue(), "Deployment should have paused-at annotation with pause-period on pod template") + }) + }) +}) diff --git a/test/e2e/annotations/resource_ignore_test.go b/test/e2e/annotations/resource_ignore_test.go new file mode 100644 index 00000000..132c91a6 --- /dev/null +++ b/test/e2e/annotations/resource_ignore_test.go @@ -0,0 +1,94 @@ +package annotations + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Resource Ignore Annotation Tests", func() { + var ( + deploymentName string + configMapName string + secretName string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + secretName = utils.RandName("secret") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName) + }) + + Context("with reloader.stakater.com/ignore annotation on resource", func() { + It("should NOT reload when ConfigMap has ignore=true annotation", func() { + By("Creating a ConfigMap with ignore=true annotation") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, + utils.BuildIgnoreAnnotation()) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with ConfigMap reference annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildConfigMapReloadAnnotation(configMapName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (negative test)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when ConfigMap has ignore=true") + }) + + It("should NOT reload when Secret has ignore=true annotation", func() { + By("Creating a Secret with ignore=true annotation") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, + utils.BuildIgnoreAnnotation()) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with Secret reference annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithSecretEnvFrom(secretName), + utils.WithAnnotations(utils.BuildSecretReloadAnnotation(secretName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret data") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (negative test)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when Secret has ignore=true") + }) + }) +}) diff --git a/test/e2e/annotations/search_match_test.go b/test/e2e/annotations/search_match_test.go new file mode 100644 index 00000000..02a1153c --- /dev/null +++ b/test/e2e/annotations/search_match_test.go @@ -0,0 +1,215 @@ +package annotations + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Search and Match Annotation Tests", func() { + var ( + deploymentName string + configMapName string + workloadName string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + workloadName = utils.RandName("workload") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + }) + + Context("with search and match annotations", func() { + It("should reload when workload has search annotation and ConfigMap has match annotation", func() { + By("Creating a ConfigMap with match annotation") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, + utils.BuildMatchAnnotation()) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with search annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildSearchAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment with search annotation should reload when ConfigMap has match annotation") + }) + + It("should NOT reload when workload has search but ConfigMap has no match", func() { + By("Creating a ConfigMap WITHOUT match annotation") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with search annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildSearchAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (negative test)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when ConfigMap lacks match annotation") + }) + + It("should NOT reload when resource has match but no Deployment has search", func() { + By("Creating a ConfigMap WITH match annotation") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, + utils.BuildMatchAnnotation()) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment WITHOUT search annotation (only standard annotation)") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName)) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (negative test)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment without search annotation should NOT reload even when ConfigMap has match") + }) + + It("should reload only the deployment with search annotation when multiple deployments use same ConfigMap", func() { + deploymentName2 := utils.RandName("deploy2") + defer func() { + _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName2) + }() + + By("Creating a ConfigMap with match annotation") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, + utils.BuildMatchAnnotation()) + Expect(err).NotTo(HaveOccurred()) + + By("Creating first Deployment WITH search annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildSearchAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Creating second Deployment WITHOUT search annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName2, + utils.WithConfigMapEnvFrom(configMapName), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for both Deployments to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + err = adapter.WaitReady(ctx, testNamespace, deploymentName2, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for first Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment with search annotation should reload") + + By("Verifying second Deployment was NOT reloaded") + reloaded2, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName2, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded2).To(BeFalse(), "Deployment without search annotation should NOT reload") + }) + }) + + // TODO: Reloader currently only reads search annotations from workload metadata, not pod template. + // This test documents the expected behavior but needs Reloader code changes to pass. + Context("with search annotation on pod template", func() { + PDescribeTable("should reload when search annotation is on pod template only", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap with match annotation") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, + utils.BuildMatchAnnotation()) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with search annotation on pod template ONLY") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + PodTemplateAnnotations: utils.BuildSearchAnnotation(), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should reload with search annotation on pod template", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + }) +}) diff --git a/test/e2e/argo/argo_suite_test.go b/test/e2e/argo/argo_suite_test.go new file mode 100644 index 00000000..b34bf379 --- /dev/null +++ b/test/e2e/argo/argo_suite_test.go @@ -0,0 +1,89 @@ +package argo + +import ( + "context" + "encoding/json" + "testing" + + rolloutsclient "github.com/argoproj/argo-rollouts/pkg/client/clientset/versioned" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var ( + kubeClient kubernetes.Interface + rolloutsClient rolloutsclient.Interface + testNamespace string + ctx context.Context + testEnv *utils.TestEnvironment +) + +func TestArgo(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Argo Rollouts E2E Suite") +} + +// SynchronizedBeforeSuite ensures only process 1 deploys Reloader. +// Process 1 also checks for Argo Rollouts and calls Skip if not installed — +// Ginkgo propagates the skip to all processes. +var _ = SynchronizedBeforeSuite( + // Process 1 only: check prerequisites, create namespace, deploy Reloader. + func() []byte { + setupEnv, err := utils.SetupTestEnvironment(context.Background(), "reloader-argo") + Expect(err).NotTo(HaveOccurred(), "Failed to setup test environment") + // Ensure the namespace is deleted even if DeployAndWait fails, so + // orphaned namespaces don't accumulate on long-lived clusters. + DeferCleanup(setupEnv.CleanupOnFailure) + + if !utils.IsArgoRolloutsInstalled(context.Background(), setupEnv.RolloutsClient) { + Skip("Argo Rollouts is not installed. Run ./scripts/e2e-cluster-setup.sh first") + } + GinkgoWriter.Println("Argo Rollouts is installed") + + Expect(setupEnv.DeployAndWait(map[string]string{ + "reloader.reloadStrategy": "annotations", + "reloader.isArgoRollouts": "true", + })).To(Succeed(), "Failed to deploy Reloader") + + data, err := json.Marshal(utils.SharedEnvData{ + Namespace: setupEnv.Namespace, + ReleaseName: setupEnv.ReleaseName, + }) + Expect(err).NotTo(HaveOccurred()) + return data + }, + // All processes (including #1): connect to the shared environment. + func(data []byte) { + var shared utils.SharedEnvData + Expect(json.Unmarshal(data, &shared)).To(Succeed()) + + var err error + testEnv, err = utils.SetupSharedTestEnvironment(context.Background(), shared.Namespace, shared.ReleaseName) + Expect(err).NotTo(HaveOccurred(), "Failed to setup shared test environment") + + kubeClient = testEnv.KubeClient + rolloutsClient = testEnv.RolloutsClient + testNamespace = testEnv.Namespace + ctx = testEnv.Ctx + }, +) + +var _ = SynchronizedAfterSuite( + // All processes: cancel the per-process context. + func() { + if testEnv != nil { + testEnv.Cancel() + } + }, + // Process 1 only (runs last): undeploy Reloader and delete namespace. + func() { + if testEnv != nil { + err := testEnv.Cleanup() + Expect(err).NotTo(HaveOccurred(), "Failed to cleanup test environment") + } + GinkgoWriter.Println("Argo Rollouts E2E Suite cleanup complete (Argo Rollouts preserved for other suites)") + }, +) diff --git a/test/e2e/argo/rollout_test.go b/test/e2e/argo/rollout_test.go new file mode 100644 index 00000000..019df62b --- /dev/null +++ b/test/e2e/argo/rollout_test.go @@ -0,0 +1,89 @@ +package argo + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +// Note: Basic Argo Rollout reload tests (ConfigMap, Secret, auto=true, volume mounts, label-only negative) +// are covered by core/workloads_test.go with Label("argo"). +// This file contains only Argo-specific tests that cannot be parameterized. + +var _ = Describe("Argo Rollout Strategy Tests", func() { + var ( + rolloutName string + configMapName string + adapter *utils.ArgoRolloutAdapter + ) + + BeforeEach(func() { + rolloutName = utils.RandName("rollout") + configMapName = utils.RandName("cm") + adapter = utils.NewArgoRolloutAdapter(rolloutsClient) + }) + + AfterEach(func() { + _ = utils.DeleteRollout(ctx, rolloutsClient, testNamespace, rolloutName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + }) + + Context("Rollout strategy annotation", func() { + It("should use default rollout strategy (annotation-based reload)", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating an Argo Rollout with auto=true (default strategy)") + _, err = utils.CreateRollout(ctx, rolloutsClient, testNamespace, rolloutName, + utils.WithRolloutConfigMapEnvFrom(configMapName), + utils.WithRolloutAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Rollout to be ready") + err = adapter.WaitReady(ctx, testNamespace, rolloutName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Rollout to be reloaded with annotation") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, rolloutName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Argo Rollout should be reloaded with default rollout strategy") + }) + + It("should use restart strategy when specified (sets restartAt field)", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating an Argo Rollout with restart strategy annotation") + _, err = utils.CreateRollout(ctx, rolloutsClient, testNamespace, rolloutName, + utils.WithRolloutConfigMapEnvFrom(configMapName), + utils.WithRolloutAnnotations(utils.BuildAutoTrueAnnotation()), + utils.WithRolloutObjectAnnotations(utils.BuildRolloutRestartStrategyAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Rollout to be ready") + err = adapter.WaitReady(ctx, testNamespace, rolloutName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Rollout to have restartAt field set") + restarted, err := adapter.WaitRestartAt(ctx, testNamespace, rolloutName, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(restarted).To(BeTrue(), "Argo Rollout should have restartAt field set with restart strategy") + }) + }) +}) diff --git a/test/e2e/core/core_suite_test.go b/test/e2e/core/core_suite_test.go new file mode 100644 index 00000000..acf7bf6e --- /dev/null +++ b/test/e2e/core/core_suite_test.go @@ -0,0 +1,112 @@ +package core + +import ( + "context" + "encoding/json" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var ( + kubeClient kubernetes.Interface + csiClient csiclient.Interface + restConfig *rest.Config + testNamespace string + ctx context.Context + testEnv *utils.TestEnvironment + registry *utils.AdapterRegistry +) + +func TestCore(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Core Workload E2E Suite") +} + +// SynchronizedBeforeSuite ensures only process 1 deploys Reloader. +// The namespace and release name are forwarded to all other processes so they +// share a single Reloader instance, avoiding resource exhaustion on Kind. +var _ = SynchronizedBeforeSuite( + // Process 1 only: create namespace, deploy Reloader. + func() []byte { + setupEnv, err := utils.SetupTestEnvironment(context.Background(), "reloader-core-test") + Expect(err).NotTo(HaveOccurred(), "Failed to setup test environment") + // Ensure the namespace is deleted even if DeployAndWait fails, so + // orphaned namespaces don't accumulate on long-lived clusters. + DeferCleanup(setupEnv.CleanupOnFailure) + + deployValues := map[string]string{ + "reloader.reloadStrategy": "annotations", + "reloader.watchGlobally": "false", + } + if utils.IsArgoRolloutsInstalled(context.Background(), setupEnv.RolloutsClient) { + deployValues["reloader.isArgoRollouts"] = "true" + GinkgoWriter.Println("Deploying Reloader with Argo Rollouts support") + } + if utils.IsCSIDriverInstalled(context.Background(), setupEnv.CSIClient) { + deployValues["reloader.enableCSIIntegration"] = "true" + GinkgoWriter.Println("Deploying Reloader with CSI integration support") + } + + Expect(setupEnv.DeployAndWait(deployValues)).To(Succeed(), "Failed to deploy Reloader") + + data, err := json.Marshal(utils.SharedEnvData{ + Namespace: setupEnv.Namespace, + ReleaseName: setupEnv.ReleaseName, + }) + Expect(err).NotTo(HaveOccurred()) + return data + }, + // All processes (including #1): connect to shared environment and build adapter registry. + func(data []byte) { + var shared utils.SharedEnvData + Expect(json.Unmarshal(data, &shared)).To(Succeed()) + + var err error + testEnv, err = utils.SetupSharedTestEnvironment(context.Background(), shared.Namespace, shared.ReleaseName) + Expect(err).NotTo(HaveOccurred(), "Failed to setup shared test environment") + + kubeClient = testEnv.KubeClient + csiClient = testEnv.CSIClient + restConfig = testEnv.RestConfig + testNamespace = testEnv.Namespace + ctx = testEnv.Ctx + + registry = utils.NewAdapterRegistry(kubeClient) + if utils.IsArgoRolloutsInstalled(ctx, testEnv.RolloutsClient) { + GinkgoWriter.Println("Argo Rollouts detected, registering ArgoRolloutAdapter") + registry.RegisterAdapter(utils.NewArgoRolloutAdapter(testEnv.RolloutsClient)) + } else { + GinkgoWriter.Println("Argo Rollouts not detected, skipping ArgoRolloutAdapter registration") + } + if utils.HasDeploymentConfigSupport(testEnv.DiscoveryClient) && testEnv.OpenShiftClient != nil { + GinkgoWriter.Println("OpenShift detected, registering DeploymentConfigAdapter") + registry.RegisterAdapter(utils.NewDeploymentConfigAdapter(testEnv.OpenShiftClient)) + } else { + GinkgoWriter.Println("OpenShift not detected, skipping DeploymentConfigAdapter registration") + } + }, +) + +var _ = SynchronizedAfterSuite( + // All processes: cancel the per-process context. + func() { + if testEnv != nil { + testEnv.Cancel() + } + }, + // Process 1 only (runs last): undeploy Reloader and delete namespace. + func() { + if testEnv != nil { + err := testEnv.Cleanup() + Expect(err).NotTo(HaveOccurred(), "Failed to cleanup test environment") + } + GinkgoWriter.Println("Core E2E Suite cleanup complete") + }, +) diff --git a/test/e2e/core/reference_methods_test.go b/test/e2e/core/reference_methods_test.go new file mode 100644 index 00000000..f3c0b8fd --- /dev/null +++ b/test/e2e/core/reference_methods_test.go @@ -0,0 +1,540 @@ +package core + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Reference Method Tests", func() { + var ( + configMapName string + secretName string + workloadName string + ) + + BeforeEach(func() { + configMapName = utils.RandName("cm") + secretName = utils.RandName("secret") + workloadName = utils.RandName("workload") + }) + + AfterEach(func() { + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName) + }) + + // ============================================================ + // valueFrom.configMapKeyRef TESTS + // ============================================================ + Context("valueFrom.configMapKeyRef", func() { + DescribeTable("should reload when ConfigMap referenced via valueFrom.configMapKeyRef changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"config_key": "initial_value"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with valueFrom.configMapKeyRef") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapKeyRef: true, + ConfigMapKey: "config_key", + EnvVarName: "MY_CONFIG_VAR", + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"config_key": "updated_value"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with valueFrom.configMapKeyRef should reload", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + }) + + // ============================================================ + // valueFrom.secretKeyRef TESTS + // ============================================================ + Context("valueFrom.secretKeyRef", func() { + DescribeTable("should reload when Secret referenced via valueFrom.secretKeyRef changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"secret_key": "initial_secret"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with valueFrom.secretKeyRef") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseSecretKeyRef: true, + SecretKey: "secret_key", + EnvVarName: "MY_SECRET_VAR", + Annotations: utils.BuildSecretReloadAnnotation(secretName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"secret_key": "updated_secret"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with valueFrom.secretKeyRef should reload", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + }) + + // ============================================================ + // PROJECTED VOLUME TESTS + // ============================================================ + Context("Projected Volumes", func() { + DescribeTable("should reload when ConfigMap in projected volume changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"config.yaml": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with projected ConfigMap volume") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseProjectedVolume: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"config.yaml": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with projected ConfigMap volume should reload", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when Secret in projected volume changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"credentials": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with projected Secret volume") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseProjectedVolume: true, + Annotations: utils.BuildSecretReloadAnnotation(secretName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"credentials": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with projected Secret volume should reload", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when ConfigMap changes in mixed projected volume", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap and Secret") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"config.yaml": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"credentials": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with projected volume containing both") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + SecretName: secretName, + UseProjectedVolume: true, + Annotations: utils.MergeAnnotations( + utils.BuildConfigMapReloadAnnotation(configMapName), + utils.BuildSecretReloadAnnotation(secretName), + ), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"config.yaml": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should reload when ConfigMap in mixed projected volume changes", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when Secret changes in mixed projected volume", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap and Secret") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"config.yaml": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"credentials": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with projected volume containing both") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + SecretName: secretName, + UseProjectedVolume: true, + Annotations: utils.MergeAnnotations( + utils.BuildConfigMapReloadAnnotation(configMapName), + utils.BuildSecretReloadAnnotation(secretName), + ), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"credentials": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should reload when Secret in mixed projected volume changes", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + }) + + // ============================================================ + // INIT CONTAINER TESTS + // ============================================================ + Context("Init Container with envFrom", func() { + DescribeTable("should reload when ConfigMap referenced by init container changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"INIT_VAR": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with init container referencing ConfigMap") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseInitContainer: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"INIT_VAR": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with init container ConfigMap should reload", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when Secret referenced by init container changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"INIT_SECRET": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with init container referencing Secret") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseInitContainer: true, + Annotations: utils.BuildSecretReloadAnnotation(secretName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"INIT_SECRET": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with init container Secret should reload", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + }) + + Context("Init Container with Volume Mount", func() { + DescribeTable("should reload when ConfigMap volume mounted in init container changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"config.yaml": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with init container using ConfigMap volume mount") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseInitContainerVolume: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"config.yaml": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with init container ConfigMap volume should reload", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when Secret volume mounted in init container changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"credentials": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with init container using Secret volume mount") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseInitContainerVolume: true, + Annotations: utils.BuildSecretReloadAnnotation(secretName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"credentials": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with init container Secret volume should reload", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + }) + + // ============================================================ + // AUTO ANNOTATION WITH VALUEFROM TESTS + // ============================================================ + Context("Auto Annotation with valueFrom", func() { + DescribeTable("should reload with auto=true when ConfigMap referenced via valueFrom changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"auto_config_key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with auto=true and valueFrom") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapKeyRef: true, + ConfigMapKey: "auto_config_key", + EnvVarName: "AUTO_CONFIG_VAR", + Annotations: utils.BuildAutoTrueAnnotation(), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"auto_config_key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with auto=true and valueFrom should reload", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + }) +}) diff --git a/test/e2e/core/workloads_test.go b/test/e2e/core/workloads_test.go new file mode 100644 index 00000000..1a7f7b37 --- /dev/null +++ b/test/e2e/core/workloads_test.go @@ -0,0 +1,1756 @@ +package core + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Workload Reload Tests", Serial, func() { + var ( + configMapName string + secretName string + workloadName string + spcName string + vaultSecretPath string + ) + + BeforeEach(func() { + configMapName = utils.RandName("cm") + secretName = utils.RandName("secret") + workloadName = utils.RandName("workload") + spcName = utils.RandName("spc") + vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("test")) + }) + + AfterEach(func() { + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName) + if csiClient != nil { + _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) + } + _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) + }) + + // ============================================================ + // ANNOTATIONS STRATEGY TESTS + // ============================================================ + Context("Annotations Strategy", func() { + // Standard workloads that support annotation-based reload + standardWorkloads := []utils.WorkloadType{ + utils.WorkloadDeployment, + utils.WorkloadDaemonSet, + utils.WorkloadStatefulSet, + } + + // ConfigMap reload tests for standard workloads + DescribeTable("should reload when ConfigMap changes", func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with ConfigMap reference annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, utils.AnnotationLastReloadedFrom, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should have been reloaded", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + // Secret reload tests for standard workloads + DescribeTable("should reload when Secret changes", func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with Secret reference annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseSecretEnvFrom: true, + Annotations: utils.BuildSecretReloadAnnotation(secretName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret data") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, utils.AnnotationLastReloadedFrom, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should have been reloaded", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + // SecretProviderClassPodStatus (CSI) reload tests with real Vault + DescribeTable("should reload when SecretProviderClassPodStatus changes", func(workloadType utils.WorkloadType) { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, + "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with CSI volume and SPC reload annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SPCName: spcName, + UseCSIVolume: true, + Annotations: utils.BuildSecretProviderClassReloadAnnotation(spcName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, + utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Printf("Found SPCPS: %s\n", spcpsName) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Printf("Initial SPCPS version: %s\n", initialVersion) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, + 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Println("CSI driver synced new secret version") + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, utils.AnnotationLastReloadedFrom, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should have been reloaded when Vault secret changed", workloadType) + }, Entry("Deployment", Label("csi"), utils.WorkloadDeployment), + Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), + Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), + ) + + // Auto=true annotation tests + DescribeTable("should reload with auto=true annotation when ConfigMap changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with auto=true annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildAutoTrueAnnotation(), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with auto=true should have been reloaded", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + // Negative tests: label-only changes should NOT trigger reload + DescribeTable("should NOT reload when only ConfigMap labels change (no data change)", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with ConfigMap reference annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating only the ConfigMap labels (no data change)") + err = utils.UpdateConfigMapLabels(ctx, kubeClient, testNamespace, configMapName, map[string]string{"new-label": "new-value"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying workload was NOT reloaded (negative test)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "%s should NOT reload when only ConfigMap labels change", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should NOT reload when only Secret labels change (no data change)", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with Secret reference annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseSecretEnvFrom: true, + Annotations: utils.BuildSecretReloadAnnotation(secretName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating only the Secret labels (no data change)") + err = utils.UpdateSecretLabels(ctx, kubeClient, testNamespace, secretName, map[string]string{"new-label": "new-value"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying workload was NOT reloaded (negative test)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "%s should NOT reload when only Secret labels change", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + // Negative test: SPCPS label-only changes should NOT trigger reload + DescribeTable("should NOT reload when only SecretProviderClassPodStatus labels change", + func(workloadType utils.WorkloadType) { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with CSI volume and SPC reload annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SPCName: spcName, + UseCSIVolume: true, + Annotations: utils.BuildSecretProviderClassReloadAnnotation(spcName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, + utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating only the SPCPS labels (no objects change)") + err = utils.UpdateSecretProviderClassPodStatusLabels(ctx, csiClient, testNamespace, spcpsName, map[string]string{"new-label": "new-value"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying workload was NOT reloaded (negative test)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "%s should NOT reload when only SPCPS labels change", workloadType) + }, Entry("Deployment", Label("csi"), utils.WorkloadDeployment), + Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), + Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), + ) + + // CronJob special handling - triggers a Job instead of annotation + Context("CronJob (special handling)", func() { + var cronJobAdapter *utils.CronJobAdapter + + BeforeEach(func() { + adapter := registry.Get(utils.WorkloadCronJob) + Expect(adapter).NotTo(BeNil()) + var ok bool + cronJobAdapter, ok = adapter.(*utils.CronJobAdapter) + Expect(ok).To(BeTrue(), "Should be able to cast to CronJobAdapter") + }) + + It("should trigger a Job when ConfigMap changes", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a CronJob with ConfigMap reference annotation") + err = cronJobAdapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = cronJobAdapter.Delete(ctx, testNamespace, workloadName) }) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for a Job to be created by CronJob reload") + triggered, err := cronJobAdapter.WaitForTriggeredJob(ctx, testNamespace, workloadName, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(triggered).To(BeTrue(), "CronJob should have triggered a Job creation") + }) + + It("should trigger a Job when Secret changes", func() { + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a CronJob with Secret reference annotation") + err = cronJobAdapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseSecretEnvFrom: true, + Annotations: utils.BuildSecretReloadAnnotation(secretName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = cronJobAdapter.Delete(ctx, testNamespace, workloadName) }) + + By("Updating the Secret data") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for a Job to be created by CronJob reload") + triggered, err := cronJobAdapter.WaitForTriggeredJob(ctx, testNamespace, workloadName, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(triggered).To(BeTrue(), "CronJob should have triggered a Job creation") + }) + + It("should trigger a Job with auto=true annotation when ConfigMap changes", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a CronJob with auto=true annotation") + err = cronJobAdapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildAutoTrueAnnotation(), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = cronJobAdapter.Delete(ctx, testNamespace, workloadName) }) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for a Job to be created by CronJob reload") + triggered, err := cronJobAdapter.WaitForTriggeredJob(ctx, testNamespace, workloadName, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(triggered).To(BeTrue(), "CronJob with auto=true should have triggered a Job creation") + }) + }) + + // Volume mount tests + DescribeTable("should reload when volume-mounted ConfigMap changes", func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"config.yaml": "setting: initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with ConfigMap volume") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapVolume: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"config.yaml": "setting: updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, utils.AnnotationLastReloadedFrom, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with volume-mounted ConfigMap should have been reloaded", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when volume-mounted Secret changes", func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"credentials.yaml": "secret: initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with Secret volume") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseSecretVolume: true, + Annotations: utils.BuildSecretReloadAnnotation(secretName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret data") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"credentials.yaml": "secret: updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, utils.AnnotationLastReloadedFrom, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with volume-mounted Secret should have been reloaded", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + // Test for workloads without Reloader annotation + DescribeTable("should NOT reload without Reloader annotation", func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "value"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload WITHOUT Reloader annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, // No Reloader annotations + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying workload is NOT reloaded (negative test)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, utils.AnnotationLastReloadedFrom, + utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "%s without Reloader annotation should NOT be reloaded", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + ) + + // Variable to track for use in lint + _ = standardWorkloads + + // ============================================================ + // EDGE CASE TESTS + // These tests verify edge cases that should work across all workload types. + // ============================================================ + Context("Edge Cases", func() { + DescribeTable("should reload with multiple ConfigMaps when any one changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + configMapName2 := utils.RandName("cm2") + DeferCleanup(func() { _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName2) }) + + By("Creating two ConfigMaps") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key1": "value1"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName2, + map[string]string{"key2": "value2"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload referencing both ConfigMaps") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName, configMapName2), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the second ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName2, map[string]string{"key2": "updated-value2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should reload when second ConfigMap changes", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload with multiple Secrets when any one changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + secretName2 := utils.RandName("secret2") + DeferCleanup(func() { _ = utils.DeleteSecret(ctx, kubeClient, testNamespace, secretName2) }) + + By("Creating two Secrets") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"key1": "value1"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName2, + map[string]string{"key2": "value2"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload referencing both Secrets") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseSecretEnvFrom: true, + Annotations: utils.BuildSecretReloadAnnotation(secretName, secretName2), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the second Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName2, map[string]string{"key2": "updated-value2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should reload when second Secret changes", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload multiple times for sequential ConfigMap updates", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "v1"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with ConfigMap reference annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("First update to ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for first reload") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue()) + + By("Getting first reload annotation value") + firstReloadValue, err := adapter.GetPodTemplateAnnotation(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom) + Expect(err).NotTo(HaveOccurred()) + + By("Second update to ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "v3"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for second reload with different annotation value") + Eventually(func() string { + val, _ := adapter.GetPodTemplateAnnotation(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom) + return val + }, utils.ReloadTimeout, utils.DefaultInterval).ShouldNot(Equal(firstReloadValue), + "Reload annotation should change after second update") + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when either ConfigMap or Secret changes", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap and Secret") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"config": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"secret": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload referencing both") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + SecretName: secretName, + UseConfigMapEnvFrom: true, + UseSecretEnvFrom: true, + Annotations: utils.MergeAnnotations( + utils.BuildConfigMapReloadAnnotation(configMapName), + utils.BuildSecretReloadAnnotation(secretName), + ), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"secret": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should reload when Secret changes", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should NOT reload with auto=false annotation", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with auto=false annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildAutoFalseAnnotation(), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying workload is NOT reloaded (auto=false)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "%s with auto=false should NOT be reloaded", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + }) + + // ============================================================ + // POD TEMPLATE ANNOTATION TESTS + // These tests verify that annotations placed on the pod template + // (spec.template.metadata.annotations) work the same as annotations + // placed on the workload metadata (metadata.annotations). + // ============================================================ + Context("Pod Template Annotations", func() { + DescribeTable("should reload when ConfigMap annotation is on pod template only", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with ConfigMap annotation on pod template ONLY") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + PodTemplateAnnotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should reload with pod template annotation", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when Secret annotation is on pod template only", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with Secret annotation on pod template ONLY") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseSecretEnvFrom: true, + PodTemplateAnnotations: utils.BuildSecretReloadAnnotation(secretName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should reload with pod template annotation", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when auto=true annotation is on pod template only", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with auto=true annotation on pod template ONLY") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + PodTemplateAnnotations: utils.BuildAutoTrueAnnotation(), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s with auto=true on pod template should reload", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when SecretProviderClass annotation is on pod template only", + func(workloadType utils.WorkloadType) { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with SPC annotation on pod template ONLY") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SPCName: spcName, + UseCSIVolume: true, + PodTemplateAnnotations: utils.BuildSecretProviderClassReloadAnnotation(spcName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, + workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, + initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should reload with SPC annotation on pod template", workloadType) + }, + Entry("Deployment", Label("csi"), utils.WorkloadDeployment), + Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), + Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when secretproviderclass auto annotation is on pod template only", + func(workloadType utils.WorkloadType) { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with SPC auto annotation on pod template ONLY") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SPCName: spcName, + UseCSIVolume: true, + PodTemplateAnnotations: utils.BuildSecretProviderClassAutoAnnotation(), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, + workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, + initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should reload with SPC auto on pod template", workloadType) + }, + Entry("Deployment", Label("csi"), utils.WorkloadDeployment), + Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), + Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should reload when annotations are on both workload and pod template", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with annotations on BOTH workload metadata and pod template") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + PodTemplateAnnotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "%s should reload with annotations on both locations", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should NOT reload when pod template has ConfigMap annotation but Secret is updated", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + By("Creating a ConfigMap and Secret") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "value"}, nil) + Expect(err).NotTo(HaveOccurred()) + + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with ConfigMap annotation on pod template but using Secret") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseSecretEnvFrom: true, + PodTemplateAnnotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret (not the ConfigMap)") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying workload was NOT reloaded (negative test)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, workloadName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "%s should NOT reload when updating different resource than annotated", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + }) + }) + + // ============================================================ + // ENVVARS STRATEGY TESTS + // ============================================================ + Context("EnvVars Strategy", Label("envvars"), Ordered, ContinueOnFailure, func() { + // Redeploy Reloader with envvars strategy for this context + BeforeAll(func() { + By("Redeploying Reloader with envvars strategy") + deployValues := map[string]string{ + "reloader.reloadStrategy": "env-vars", + } + // Preserve Argo support if available + if utils.IsArgoRolloutsInstalled(ctx, testEnv.RolloutsClient) { + deployValues["reloader.isArgoRollouts"] = "true" + } + // Enable CSI integration if CSI driver is installed + if utils.IsCSIDriverInstalled(ctx, csiClient) { + deployValues["reloader.enableCSIIntegration"] = "true" + } + err := testEnv.DeployAndWait(deployValues) + Expect(err).NotTo(HaveOccurred(), "Failed to redeploy Reloader with envvars strategy") + }) + + AfterAll(func() { + By("Restoring Reloader to annotations strategy") + deployValues := map[string]string{ + "reloader.reloadStrategy": "annotations", + } + // Preserve Argo support if available + if utils.IsArgoRolloutsInstalled(ctx, testEnv.RolloutsClient) { + deployValues["reloader.isArgoRollouts"] = "true" + } + // Preserve CSI integration if CSI driver is installed + if utils.IsCSIDriverInstalled(ctx, csiClient) { + deployValues["reloader.enableCSIIntegration"] = "true" + } + err := testEnv.DeployAndWait(deployValues) + Expect(err).NotTo(HaveOccurred(), "Failed to restore Reloader to annotations strategy") + }) + + DescribeTable("should add STAKATER_ env var when ConfigMap changes", func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + if !adapter.SupportsEnvVarStrategy() { + Skip("Workload type does not support env var strategy") + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with ConfigMap reference annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap data") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to have STAKATER_ env var") + found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue(), "%s should have STAKATER_ env var after ConfigMap change", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + DescribeTable("should add STAKATER_ env var when Secret changes", func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + if !adapter.SupportsEnvVarStrategy() { + Skip("Workload type does not support env var strategy") + } + + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with Secret reference annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseSecretEnvFrom: true, + Annotations: utils.BuildSecretReloadAnnotation(secretName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret data") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to have STAKATER_ env var") + found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue(), "%s should have STAKATER_ env var after Secret change", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("openshift"), utils.WorkloadDeploymentConfig), + ) + + // CSI SecretProviderClassPodStatus env var tests with real Vault + DescribeTable("should add STAKATER_ env var when SecretProviderClassPodStatus changes", + func(workloadType utils.WorkloadType) { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + if !adapter.SupportsEnvVarStrategy() { + Skip("Workload type does not support env var strategy") + } + + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with CSI volume and SPC reload annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SPCName: spcName, + UseCSIVolume: true, + Annotations: utils.BuildSecretProviderClassReloadAnnotation(spcName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, + utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, + 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for workload to have STAKATER_ env var") + found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue(), "%s should have STAKATER_ env var after Vault secret change", workloadType) + }, Entry("Deployment", Label("csi"), utils.WorkloadDeployment), + Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), + Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), + ) + + // Negative tests for env var strategy + DescribeTable("should NOT add STAKATER_ env var when only ConfigMap labels change", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + if !adapter.SupportsEnvVarStrategy() { + Skip("Workload type does not support env var strategy") + } + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "value"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with ConfigMap reference annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + ConfigMapName: configMapName, + UseConfigMapEnvFrom: true, + Annotations: utils.BuildConfigMapReloadAnnotation(configMapName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating only the ConfigMap labels") + err = utils.UpdateConfigMapLabels(ctx, kubeClient, testNamespace, configMapName, map[string]string{"new-label": "new-value"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying workload does NOT have STAKATER_ env var") + time.Sleep(utils.NegativeTestWait) + found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, + utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse(), "%s should NOT have STAKATER_ env var for label-only change", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + ) + + DescribeTable("should NOT add STAKATER_ env var when only Secret labels change", + func(workloadType utils.WorkloadType) { + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + if !adapter.SupportsEnvVarStrategy() { + Skip("Workload type does not support env var strategy") + } + + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, testNamespace, secretName, + map[string]string{"password": "value"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with Secret reference annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SecretName: secretName, + UseSecretEnvFrom: true, + Annotations: utils.BuildSecretReloadAnnotation(secretName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating only the Secret labels") + err = utils.UpdateSecretLabels(ctx, kubeClient, testNamespace, secretName, map[string]string{"new-label": "new-value"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying workload does NOT have STAKATER_ env var") + time.Sleep(utils.NegativeTestWait) + found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, + utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse(), "%s should NOT have STAKATER_ env var for label-only change", workloadType) + }, + Entry("Deployment", utils.WorkloadDeployment), + Entry("DaemonSet", utils.WorkloadDaemonSet), + Entry("StatefulSet", utils.WorkloadStatefulSet), + ) + + // CSI SPCPS label-only change negative test with real Vault + DescribeTable("should NOT add STAKATER_ env var when only SecretProviderClassPodStatus labels change", + func(workloadType utils.WorkloadType) { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + + adapter := registry.Get(workloadType) + if adapter == nil { + Skip(fmt.Sprintf("%s adapter not available (CRD not installed)", workloadType)) + } + + if !adapter.SupportsEnvVarStrategy() { + Skip("Workload type does not support env var strategy") + } + + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating workload with CSI volume and SPC reload annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SPCName: spcName, + UseCSIVolume: true, + Annotations: utils.BuildSecretProviderClassReloadAnnotation(spcName), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for workload to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, + utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating only the SPCPS labels (should NOT trigger reload)") + err = utils.UpdateSecretProviderClassPodStatusLabels(ctx, csiClient, testNamespace, spcpsName, map[string]string{"new-label": "new-value"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying workload does NOT have STAKATER_ env var") + time.Sleep(utils.NegativeTestWait) + found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, + utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse(), "%s should NOT have STAKATER_ env var for SPCPS label-only change", + workloadType) + }, Entry("Deployment", Label("csi"), utils.WorkloadDeployment), + Entry("DaemonSet", Label("csi"), utils.WorkloadDaemonSet), + Entry("StatefulSet", Label("csi"), utils.WorkloadStatefulSet), + Entry("ArgoRollout", Label("csi", "argo"), utils.WorkloadArgoRollout), + Entry("DeploymentConfig", Label("csi", "openshift"), utils.WorkloadDeploymentConfig), + ) + + // CSI auto annotation with EnvVar strategy and real Vault + It("should add STAKATER_ env var with secretproviderclass auto annotation", Label("csi"), func() { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + + adapter := registry.Get(utils.WorkloadDeployment) + Expect(adapter).NotTo(BeNil()) + + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, + "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating Deployment with CSI volume and SPC auto annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SPCName: spcName, + UseCSIVolume: true, + Annotations: utils.BuildSecretProviderClassAutoAnnotation(), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, + utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, + 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to have STAKATER_ env var") + found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, + utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue(), "Deployment with SPC auto annotation should have STAKATER_ env var") + }) + + // CSI exclude annotation with EnvVar strategy and real Vault + It("should NOT add STAKATER_ env var when excluded SecretProviderClassPodStatus changes", Label("csi"), func() { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + + adapter := registry.Get(utils.WorkloadDeployment) + Expect(adapter).NotTo(BeNil()) + + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, vaultSecretPath, + "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating Deployment with auto=true and SPC exclude annotation") + err = adapter.Create(ctx, testNamespace, workloadName, utils.WorkloadConfig{ + SPCName: spcName, + UseCSIVolume: true, + Annotations: utils.MergeAnnotations(utils.BuildAutoTrueAnnotation(), + utils.BuildSecretProviderClassExcludeAnnotation(spcName)), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = adapter.Delete(ctx, testNamespace, workloadName) }) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, + utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret (excluded SPC - should NOT trigger reload)") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, + 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment does NOT have STAKATER_ env var") + time.Sleep(utils.NegativeTestWait) + found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, utils.StakaterEnvVarPrefix, + utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeFalse(), "Deployment should NOT have STAKATER_ env var for excluded SPCPS change") + }) + + // CSI init container with EnvVar strategy and real Vault + It("should add STAKATER_ env var when SecretProviderClassPodStatus used by init container changes", Label("csi"), func() { + if !utils.IsCSIDriverInstalled(ctx, csiClient) { + Skip("CSI secrets store driver not installed") + } + if !utils.IsVaultProviderInstalled(ctx, kubeClient) { + Skip("Vault CSI provider not installed") + } + + By("Creating a secret in Vault") + err := utils.CreateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret(ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "api_key") + Expect(err).NotTo(HaveOccurred()) + + By("Creating Deployment with init container using CSI volume") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, workloadName, + utils.WithInitContainerCSIVolume(spcName), + utils.WithAnnotations(utils.BuildSecretProviderClassReloadAnnotation(spcName))) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, workloadName) }) + + adapter := utils.NewDeploymentAdapter(kubeClient) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, workloadName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment(ctx, csiClient, kubeClient, testNamespace, workloadName, + utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, + 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to have STAKATER_ env var") + found, err := adapter.WaitEnvVar(ctx, testNamespace, workloadName, + utils.StakaterEnvVarPrefix, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue(), "Deployment with init container CSI should have STAKATER_ env var") + }) + }) +}) diff --git a/test/e2e/csi/csi_suite_test.go b/test/e2e/csi/csi_suite_test.go new file mode 100644 index 00000000..f2e809cf --- /dev/null +++ b/test/e2e/csi/csi_suite_test.go @@ -0,0 +1,95 @@ +package csi + +import ( + "context" + "encoding/json" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var ( + kubeClient kubernetes.Interface + csiClient csiclient.Interface + restConfig *rest.Config + testNamespace string + ctx context.Context + testEnv *utils.TestEnvironment +) + +func TestCSI(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CSI SecretProviderClass E2E Suite") +} + +// SynchronizedBeforeSuite ensures only process 1 deploys Reloader. +// Process 1 also checks prerequisites (CSI driver, Vault) and calls Skip if +// they are not installed — Ginkgo propagates the skip to all processes. +var _ = SynchronizedBeforeSuite( + // Process 1 only: check prerequisites, create namespace, deploy Reloader. + func() []byte { + setupEnv, err := utils.SetupTestEnvironment(context.Background(), "reloader-csi-test") + Expect(err).NotTo(HaveOccurred(), "Failed to setup test environment") + // Ensure the namespace is deleted even if DeployAndWait fails, so + // orphaned namespaces don't accumulate on long-lived clusters. + DeferCleanup(setupEnv.CleanupOnFailure) + + if !utils.IsCSIDriverInstalled(context.Background(), setupEnv.CSIClient) { + Skip("CSI secrets store driver not installed - skipping CSI suite") + } + if !utils.IsVaultProviderInstalled(context.Background(), setupEnv.KubeClient) { + Skip("Vault CSI provider not installed - skipping CSI suite") + } + + Expect(setupEnv.DeployAndWait(map[string]string{ + "reloader.reloadStrategy": "annotations", + "reloader.watchGlobally": "false", + "reloader.enableCSIIntegration": "true", + })).To(Succeed(), "Failed to deploy Reloader") + + data, err := json.Marshal(utils.SharedEnvData{ + Namespace: setupEnv.Namespace, + ReleaseName: setupEnv.ReleaseName, + }) + Expect(err).NotTo(HaveOccurred()) + return data + }, + // All processes (including #1): connect to the shared environment. + func(data []byte) { + var shared utils.SharedEnvData + Expect(json.Unmarshal(data, &shared)).To(Succeed()) + + var err error + testEnv, err = utils.SetupSharedTestEnvironment(context.Background(), shared.Namespace, shared.ReleaseName) + Expect(err).NotTo(HaveOccurred(), "Failed to setup shared test environment") + + kubeClient = testEnv.KubeClient + csiClient = testEnv.CSIClient + restConfig = testEnv.RestConfig + testNamespace = testEnv.Namespace + ctx = testEnv.Ctx + }, +) + +var _ = SynchronizedAfterSuite( + // All processes: cancel the per-process context. + func() { + if testEnv != nil { + testEnv.Cancel() + } + }, + // Process 1 only (runs last): undeploy Reloader and delete namespace. + func() { + if testEnv != nil { + err := testEnv.Cleanup() + Expect(err).NotTo(HaveOccurred(), "Failed to cleanup test environment") + } + GinkgoWriter.Println("CSI E2E Suite cleanup complete") + }, +) diff --git a/test/e2e/csi/csi_test.go b/test/e2e/csi/csi_test.go new file mode 100644 index 00000000..ef55f2bd --- /dev/null +++ b/test/e2e/csi/csi_test.go @@ -0,0 +1,330 @@ +package csi + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("CSI SecretProviderClass Tests", Label("csi"), Serial, func() { + var ( + deploymentName string + configMapName string + spcName string + vaultSecretPath string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + spcName = utils.RandName("spc") + vaultSecretPath = fmt.Sprintf("secret/%s", utils.RandName("test")) + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + _ = utils.DeleteSecretProviderClass(ctx, csiClient, testNamespace, spcName) + _ = utils.DeleteVaultSecret(ctx, kubeClient, restConfig, vaultSecretPath) + }) + + Context("Real Vault Integration Tests", func() { + It("should reload when Vault secret changes", func() { + By("Creating a secret in Vault") + err := utils.CreateVaultSecret( + ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "initial-value-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret( + ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "api_key", + ) + Expect(err).NotTo(HaveOccurred()) + + By("Creating Deployment with CSI volume and SPC reload annotation") + _, err = utils.CreateDeployment( + ctx, kubeClient, testNamespace, deploymentName, + utils.WithCSIVolume(spcName), + utils.WithAnnotations(utils.BuildSecretProviderClassReloadAnnotation(spcName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS created by CSI driver") + spcpsName, err := utils.FindSPCPSForDeployment( + ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout, + ) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Printf("Found SPCPS: %s\n", spcpsName) + + By("Getting initial SPCPS version") + initialVersion, err := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Printf("Initial SPCPS version: %s\n", initialVersion) + + By("Updating the Vault secret") + err = utils.UpdateVaultSecret( + ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"api_key": "updated-value-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync the new secret version") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Println("CSI driver synced new secret version") + + By("Waiting for Deployment to be reloaded by Reloader") + reloaded, err := adapter.WaitReloaded( + ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should have been reloaded after Vault secret change") + }) + + It("should handle multiple Vault secret updates", func() { + By("Creating a secret in Vault") + err := utils.CreateVaultSecret( + ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"password": "pass-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret( + ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "password", + ) + Expect(err).NotTo(HaveOccurred()) + + By("Creating Deployment with CSI volume") + _, err = utils.CreateDeployment( + ctx, kubeClient, testNamespace, deploymentName, + utils.WithCSIVolume(spcName), + utils.WithAnnotations(utils.BuildSecretProviderClassReloadAnnotation(spcName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the SPCPS") + spcpsName, err := utils.FindSPCPSForDeployment( + ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout, + ) + Expect(err).NotTo(HaveOccurred()) + + By("First update to Vault secret") + initialVersion, _ := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + err = utils.UpdateVaultSecret( + ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"password": "pass-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for first CSI sync") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for first reload") + reloaded, err := adapter.WaitReloaded( + ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue()) + + By("Getting annotation value after first reload") + deploy, err := utils.GetDeployment(ctx, kubeClient, testNamespace, deploymentName) + Expect(err).NotTo(HaveOccurred()) + firstReloadValue := deploy.Spec.Template.Annotations[utils.AnnotationLastReloadedFrom] + Expect(firstReloadValue).NotTo(BeEmpty()) + + By("Waiting for Deployment to stabilize") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Finding the NEW SPCPS after first reload (new pod = new SPCPS)") + newSpcpsName, err := utils.FindSPCPSForDeployment( + ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout, + ) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Printf("New SPCPS after first reload: %s\n", newSpcpsName) + + By("Second update to Vault secret") + err = utils.UpdateVaultSecret( + ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"password": "pass-v3"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for second reload with different annotation value") + Eventually(func() string { + deploy, err := utils.GetDeployment(ctx, kubeClient, testNamespace, deploymentName) + if err != nil { + return "" + } + return deploy.Spec.Template.Annotations[utils.AnnotationLastReloadedFrom] + }, utils.ReloadTimeout).ShouldNot(Equal(firstReloadValue), "Annotation should change after second Vault secret update") + }) + }) + + Context("Typed Auto Annotation Tests", func() { + It("should reload only SPC changes with secretproviderclass auto annotation, not ConfigMap", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap( + ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil, + ) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a secret in Vault") + err = utils.CreateVaultSecret( + ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"token": "token-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret( + ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "token", + ) + Expect(err).NotTo(HaveOccurred()) + + By("Creating Deployment with ConfigMap envFrom AND CSI volume, but only SPC auto annotation") + _, err = utils.CreateDeployment( + ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithCSIVolume(spcName), + utils.WithAnnotations(utils.BuildSecretProviderClassAutoAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap (should NOT trigger reload)") + err = utils.UpdateConfigMap( + ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded for ConfigMap change") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded( + ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "SPC auto annotation should not trigger reload for ConfigMap changes") + + By("Finding the SPCPS") + spcpsName, err := utils.FindSPCPSForDeployment( + ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout, + ) + Expect(err).NotTo(HaveOccurred()) + + By("Getting SPCPS version before Vault update") + initialVersion, _ := utils.GetSPCPSVersion(ctx, csiClient, testNamespace, spcpsName) + + By("Updating the Vault secret (should trigger reload)") + err = utils.UpdateVaultSecret( + ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"token": "token-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for CSI driver to sync") + err = utils.WaitForSPCPSVersionChange(ctx, csiClient, testNamespace, spcpsName, initialVersion, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment WAS reloaded for Vault secret change") + reloaded, err = adapter.WaitReloaded( + ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "SPC auto annotation should trigger reload for Vault secret changes") + }) + + It("should reload for both ConfigMap and SPC when using combined auto=true", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap( + ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil, + ) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a secret in Vault") + err = utils.CreateVaultSecret( + ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"secret": "secret-v1"}) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a SecretProviderClass pointing to Vault secret") + _, err = utils.CreateSecretProviderClassWithSecret( + ctx, csiClient, testNamespace, spcName, + vaultSecretPath, "secret", + ) + Expect(err).NotTo(HaveOccurred()) + + By("Creating Deployment with ConfigMap envFrom AND CSI volume with combined auto=true") + _, err = utils.CreateDeployment( + ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithCSIVolume(spcName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap (should trigger reload with auto=true)") + err = utils.UpdateConfigMap( + ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment WAS reloaded for ConfigMap change") + reloaded, err := adapter.WaitReloaded( + ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Combined auto=true should trigger reload for ConfigMap changes") + + By("Waiting for Deployment to stabilize") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Getting current annotation value") + deploy, err := utils.GetDeployment(ctx, kubeClient, testNamespace, deploymentName) + Expect(err).NotTo(HaveOccurred()) + firstReloadValue := deploy.Spec.Template.Annotations[utils.AnnotationLastReloadedFrom] + + By("Finding the NEW SPCPS after ConfigMap reload (new pod = new SPCPS)") + newSpcpsName, err := utils.FindSPCPSForDeployment( + ctx, csiClient, kubeClient, testNamespace, deploymentName, utils.WorkloadReadyTimeout, + ) + Expect(err).NotTo(HaveOccurred()) + GinkgoWriter.Printf("New SPCPS after ConfigMap reload: %s\n", newSpcpsName) + + By("Updating the Vault secret (should also trigger reload with auto=true)") + err = utils.UpdateVaultSecret( + ctx, kubeClient, restConfig, vaultSecretPath, map[string]string{"secret": "secret-v2"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment WAS reloaded for Vault secret change") + Eventually(func() string { + deploy, err := utils.GetDeployment(ctx, kubeClient, testNamespace, deploymentName) + if err != nil { + return "" + } + return deploy.Spec.Template.Annotations[utils.AnnotationLastReloadedFrom] + }, utils.ReloadTimeout).ShouldNot(Equal(firstReloadValue), + "Combined auto=true should trigger reload for Vault secret changes", + ) + }) + }) +}) diff --git a/test/e2e/flags/auto_reload_all_test.go b/test/e2e/flags/auto_reload_all_test.go new file mode 100644 index 00000000..f4cda1cd --- /dev/null +++ b/test/e2e/flags/auto_reload_all_test.go @@ -0,0 +1,107 @@ +package flags + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Auto Reload All Flag Tests", Serial, func() { + var ( + deploymentName string + configMapName string + autoNamespace string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + autoNamespace = "auto-" + utils.RandName("ns") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, autoNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, autoNamespace, configMapName) + }) + + Context("with autoReloadAll=true flag", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, autoNamespace) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.autoReloadAll": "true", + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, autoNamespace) + }) + + It("should reload workloads without any annotations when autoReloadAll is true", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, autoNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment WITHOUT any Reloader annotations") + _, err = utils.CreateDeployment(ctx, kubeClient, autoNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, autoNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, autoNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded (autoReloadAll=true)") + reloaded, err := adapter.WaitReloaded(ctx, autoNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment without annotations should reload when autoReloadAll=true") + }) + + It("should respect auto=false annotation even when autoReloadAll is true", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, autoNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto=false annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, autoNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoFalseAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, autoNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, autoNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (auto=false overrides autoReloadAll)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, autoNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment with auto=false should NOT reload even with autoReloadAll=true") + }) + }) +}) diff --git a/test/e2e/flags/flags_suite_test.go b/test/e2e/flags/flags_suite_test.go new file mode 100644 index 00000000..15a87039 --- /dev/null +++ b/test/e2e/flags/flags_suite_test.go @@ -0,0 +1,97 @@ +package flags + +import ( + "context" + "encoding/json" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var ( + kubeClient kubernetes.Interface + testNamespace string + ctx context.Context + testEnv *utils.TestEnvironment +) + +func TestFlags(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Flag-Based E2E Suite") +} + +// SynchronizedBeforeSuite ensures only process 1 creates the shared namespace. +// The flags tests each deploy/undeploy Reloader themselves (marked Serial), so +// there is no shared Reloader instance — only the namespace is shared. +var _ = SynchronizedBeforeSuite( + // Process 1 only: create namespace, build clients. + func() []byte { + setupEnv, err := utils.SetupTestEnvironment(context.Background(), "reloader-flags") + Expect(err).NotTo(HaveOccurred(), "Failed to setup test environment") + // Ensure the namespace is cleaned up if setup fails. + DeferCleanup(setupEnv.CleanupOnFailure) + + data, err := json.Marshal(utils.SharedEnvData{ + Namespace: setupEnv.Namespace, + ReleaseName: setupEnv.ReleaseName, + }) + Expect(err).NotTo(HaveOccurred()) + return data + }, + // All processes (including #1): connect to the shared namespace. + func(data []byte) { + var shared utils.SharedEnvData + Expect(json.Unmarshal(data, &shared)).To(Succeed()) + + var err error + testEnv, err = utils.SetupSharedTestEnvironment(context.Background(), shared.Namespace, shared.ReleaseName) + Expect(err).NotTo(HaveOccurred(), "Failed to setup shared test environment") + + kubeClient = testEnv.KubeClient + testNamespace = testEnv.Namespace + ctx = testEnv.Ctx + }, +) + +var _ = SynchronizedAfterSuite( + // All processes: cancel the per-process context. + func() { + if testEnv != nil { + testEnv.Cancel() + } + }, + // Process 1 only (runs last): delete namespace. + func() { + if testEnv != nil { + err := testEnv.Cleanup() + Expect(err).NotTo(HaveOccurred(), "Failed to cleanup test environment") + } + GinkgoWriter.Println("Flags E2E Suite cleanup complete") + }, +) + +// deployReloaderWithFlags deploys Reloader with the specified Helm value overrides. +// This is a convenience function for tests that need to deploy with specific flags. +func deployReloaderWithFlags(values map[string]string) error { + if values == nil { + values = make(map[string]string) + } + if _, ok := values["reloader.reloadStrategy"]; !ok { + values["reloader.reloadStrategy"] = "annotations" + } + return testEnv.DeployAndWait(values) +} + +// undeployReloader removes the Reloader installation. +func undeployReloader() error { + return utils.UndeployReloader(testNamespace, testEnv.ReleaseName) +} + +// waitForReloaderReady waits for the Reloader deployment to be ready. +func waitForReloaderReady() error { + return testEnv.WaitForReloader() +} diff --git a/test/e2e/flags/ignore_resources_test.go b/test/e2e/flags/ignore_resources_test.go new file mode 100644 index 00000000..44eb539d --- /dev/null +++ b/test/e2e/flags/ignore_resources_test.go @@ -0,0 +1,188 @@ +package flags + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Ignore Resources Flag Tests", Serial, func() { + var ( + deploymentName string + configMapName string + secretName string + ignoreNS string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + secretName = utils.RandName("secret") + ignoreNS = "ignore-" + utils.RandName("ns") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, ignoreNS, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, ignoreNS, configMapName) + _ = utils.DeleteSecret(ctx, kubeClient, ignoreNS, secretName) + }) + + Context("with ignoreSecrets=true flag", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, ignoreNS) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.ignoreSecrets": "true", + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, ignoreNS) + }) + + It("should NOT reload when Secret changes with ignoreSecrets=true", func() { + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, ignoreNS, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto annotation referencing the Secret") + _, err = utils.CreateDeployment(ctx, kubeClient, ignoreNS, deploymentName, + utils.WithSecretEnvFrom(secretName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, ignoreNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, ignoreNS, secretName, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (ignoreSecrets=true)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, ignoreNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when ignoreSecrets=true") + }) + + It("should still reload when ConfigMap changes with ignoreSecrets=true", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, ignoreNS, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto annotation referencing the ConfigMap") + _, err = utils.CreateDeployment(ctx, kubeClient, ignoreNS, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, ignoreNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, ignoreNS, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded (ConfigMap should still work)") + reloaded, err := adapter.WaitReloaded(ctx, ignoreNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "ConfigMap changes should still trigger reload with ignoreSecrets=true") + }) + }) + + Context("with ignoreConfigMaps=true flag", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, ignoreNS) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.ignoreConfigMaps": "true", + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, ignoreNS) + }) + + It("should NOT reload when ConfigMap changes with ignoreConfigMaps=true", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, ignoreNS, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto annotation referencing the ConfigMap") + _, err = utils.CreateDeployment(ctx, kubeClient, ignoreNS, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, ignoreNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, ignoreNS, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (ignoreConfigMaps=true)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, ignoreNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when ignoreConfigMaps=true") + }) + + It("should still reload when Secret changes with ignoreConfigMaps=true", func() { + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, ignoreNS, secretName, + map[string]string{"password": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto annotation referencing the Secret") + _, err = utils.CreateDeployment(ctx, kubeClient, ignoreNS, deploymentName, + utils.WithSecretEnvFrom(secretName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, ignoreNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the Secret") + err = utils.UpdateSecretFromStrings(ctx, kubeClient, ignoreNS, secretName, map[string]string{"password": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded (Secret should still work)") + reloaded, err := adapter.WaitReloaded(ctx, ignoreNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Secret changes should still trigger reload with ignoreConfigMaps=true") + }) + }) +}) diff --git a/test/e2e/flags/ignored_workloads_test.go b/test/e2e/flags/ignored_workloads_test.go new file mode 100644 index 00000000..7a5185ce --- /dev/null +++ b/test/e2e/flags/ignored_workloads_test.go @@ -0,0 +1,157 @@ +package flags + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Ignored Workloads Flag Tests", Serial, func() { + var ( + cronJobName string + configMapName string + ignoreNS string + cronJobAdapter *utils.CronJobAdapter + deploymentAdapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + cronJobName = utils.RandName("cj") + configMapName = utils.RandName("cm") + ignoreNS = "ignore-wl-" + utils.RandName("ns") + cronJobAdapter = utils.NewCronJobAdapter(kubeClient) + deploymentAdapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteCronJob(ctx, kubeClient, ignoreNS, cronJobName) + _ = utils.DeleteConfigMap(ctx, kubeClient, ignoreNS, configMapName) + }) + + Context("with ignoreCronJobs=true flag", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, ignoreNS) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.ignoreCronJobs": "true", + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, ignoreNS) + }) + + It("should NOT reload CronJobs when ignoreCronJobs=true", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, ignoreNS, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a CronJob with auto annotation referencing the ConfigMap") + _, err = utils.CreateCronJob(ctx, kubeClient, ignoreNS, cronJobName, + utils.WithCronJobConfigMapEnvFrom(configMapName), + utils.WithCronJobAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, ignoreNS, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying CronJob was NOT reloaded (ignoreCronJobs=true)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := cronJobAdapter.WaitReloaded(ctx, ignoreNS, cronJobName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "CronJob should NOT reload when ignoreCronJobs=true") + }) + + It("should still reload Deployments when ignoreCronJobs=true", func() { + deploymentName := utils.RandName("deploy") + + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, ignoreNS, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto annotation referencing the ConfigMap") + _, err = utils.CreateDeployment(ctx, kubeClient, ignoreNS, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + defer func() { + _ = utils.DeleteDeployment(ctx, kubeClient, ignoreNS, deploymentName) + }() + + By("Waiting for Deployment to be ready") + err = deploymentAdapter.WaitReady(ctx, ignoreNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, ignoreNS, configMapName, map[string]string{"key": "updated-deploy"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded (Deployment should still work)") + reloaded, err := deploymentAdapter.WaitReloaded(ctx, ignoreNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should still reload with ignoreCronJobs=true") + }) + }) + + Context("with both ignoreCronJobs=true and ignoreJobs=true flags", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, ignoreNS) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.ignoreCronJobs": "true", + "reloader.ignoreJobs": "true", + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, ignoreNS) + }) + + It("should NOT reload CronJobs when both job flags are true", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, ignoreNS, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a CronJob with auto annotation") + _, err = utils.CreateCronJob(ctx, kubeClient, ignoreNS, cronJobName, + utils.WithCronJobConfigMapEnvFrom(configMapName), + utils.WithCronJobAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, ignoreNS, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying CronJob was NOT reloaded") + time.Sleep(utils.NegativeTestWait) + reloaded, err := cronJobAdapter.WaitReloaded(ctx, ignoreNS, cronJobName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "CronJob should NOT reload when ignoreCronJobs=true and ignoreJobs=true") + }) + }) +}) diff --git a/test/e2e/flags/namespace_ignore_test.go b/test/e2e/flags/namespace_ignore_test.go new file mode 100644 index 00000000..39467909 --- /dev/null +++ b/test/e2e/flags/namespace_ignore_test.go @@ -0,0 +1,115 @@ +package flags + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Namespace Ignore Flag Tests", Serial, func() { + var ( + deploymentName string + configMapName string + ignoredNamespace string + watchedNamespace string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + ignoredNamespace = "ignored-" + utils.RandName("ns") + watchedNamespace = "watched-" + utils.RandName("ns") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, ignoredNamespace, deploymentName) + _ = utils.DeleteDeployment(ctx, kubeClient, watchedNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, ignoredNamespace, configMapName) + _ = utils.DeleteConfigMap(ctx, kubeClient, watchedNamespace, configMapName) + }) + + Context("with ignoreNamespaces flag", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, ignoredNamespace) + Expect(err).NotTo(HaveOccurred()) + err = utils.CreateNamespace(ctx, kubeClient, watchedNamespace) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.ignoreNamespaces": ignoredNamespace, + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, ignoredNamespace) + _ = utils.DeleteNamespace(ctx, kubeClient, watchedNamespace) + }) + + It("should NOT reload in ignored namespace", func() { + By("Creating a ConfigMap in the ignored namespace") + _, err := utils.CreateConfigMap(ctx, kubeClient, ignoredNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment in the ignored namespace") + _, err = utils.CreateDeployment(ctx, kubeClient, ignoredNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildConfigMapReloadAnnotation(configMapName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, ignoredNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, ignoredNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (ignored namespace)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, ignoredNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment in ignored namespace should NOT be reloaded") + }) + + It("should reload in watched (non-ignored) namespace", func() { + By("Creating a ConfigMap in the watched namespace") + _, err := utils.CreateConfigMap(ctx, kubeClient, watchedNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment in the watched namespace") + _, err = utils.CreateDeployment(ctx, kubeClient, watchedNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildConfigMapReloadAnnotation(configMapName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, watchedNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, watchedNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, watchedNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment in non-ignored namespace should be reloaded") + }) + }) +}) diff --git a/test/e2e/flags/namespace_selector_test.go b/test/e2e/flags/namespace_selector_test.go new file mode 100644 index 00000000..4ac49cbe --- /dev/null +++ b/test/e2e/flags/namespace_selector_test.go @@ -0,0 +1,116 @@ +package flags + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Namespace Selector Flag Tests", Serial, func() { + var ( + deploymentName string + configMapName string + matchingNS string + nonMatchingNS string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + matchingNS = "match-" + utils.RandName("ns") + nonMatchingNS = "nomatch-" + utils.RandName("ns") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, matchingNS, deploymentName) + _ = utils.DeleteDeployment(ctx, kubeClient, nonMatchingNS, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, matchingNS, configMapName) + _ = utils.DeleteConfigMap(ctx, kubeClient, nonMatchingNS, configMapName) + }) + + Context("with namespaceSelector flag", func() { + BeforeEach(func() { + err := utils.CreateNamespaceWithLabels(ctx, kubeClient, matchingNS, map[string]string{"env": "test"}) + Expect(err).NotTo(HaveOccurred()) + + err = utils.CreateNamespace(ctx, kubeClient, nonMatchingNS) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.namespaceSelector": "env=test", + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, matchingNS) + _ = utils.DeleteNamespace(ctx, kubeClient, nonMatchingNS) + }) + + It("should reload workloads in matching namespaces", func() { + By("Creating a ConfigMap in matching namespace") + _, err := utils.CreateConfigMap(ctx, kubeClient, matchingNS, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment in matching namespace with auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, matchingNS, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, matchingNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, matchingNS, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, matchingNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment in matching namespace should be reloaded") + }) + + It("should NOT reload workloads in non-matching namespaces", func() { + By("Creating a ConfigMap in non-matching namespace") + _, err := utils.CreateConfigMap(ctx, kubeClient, nonMatchingNS, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment in non-matching namespace with auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, nonMatchingNS, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, nonMatchingNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, nonMatchingNS, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (non-matching namespace)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, nonMatchingNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment in non-matching namespace should NOT be reloaded") + }) + }) +}) diff --git a/test/e2e/flags/reload_on_create_test.go b/test/e2e/flags/reload_on_create_test.go new file mode 100644 index 00000000..63fec0bb --- /dev/null +++ b/test/e2e/flags/reload_on_create_test.go @@ -0,0 +1,142 @@ +package flags + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Reload On Create Flag Tests", Serial, func() { + var ( + deploymentName string + configMapName string + createNamespace string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + createNamespace = "create-" + utils.RandName("ns") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, createNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, createNamespace, configMapName) + }) + + Context("with reloadOnCreate=true flag", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, createNamespace) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.reloadOnCreate": "true", + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, createNamespace) + }) + + It("should reload when a new ConfigMap is created", func() { + By("Creating a Deployment with annotation for a ConfigMap that doesn't exist yet") + _, err := utils.CreateDeployment(ctx, kubeClient, createNamespace, deploymentName, + utils.WithAnnotations(utils.BuildConfigMapReloadAnnotation(configMapName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, createNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Creating the ConfigMap that the Deployment references") + _, err = utils.CreateConfigMap(ctx, kubeClient, createNamespace, configMapName, + map[string]string{"key": "value"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded (reloadOnCreate=true)") + reloaded, err := adapter.WaitReloaded(ctx, createNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when referenced ConfigMap is created") + }) + + It("should reload when a new Secret is created", func() { + secretName := utils.RandName("secret") + defer func() { _ = utils.DeleteSecret(ctx, kubeClient, createNamespace, secretName) }() + + By("Creating a Deployment with annotation for a Secret that doesn't exist yet") + _, err := utils.CreateDeployment(ctx, kubeClient, createNamespace, deploymentName, + utils.WithAnnotations(utils.BuildSecretReloadAnnotation(secretName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, createNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Creating the Secret that the Deployment references") + _, err = utils.CreateSecretFromStrings(ctx, kubeClient, createNamespace, secretName, + map[string]string{"password": "secret"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded (reloadOnCreate=true)") + reloaded, err := adapter.WaitReloaded(ctx, createNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when referenced Secret is created") + }) + }) + + Context("with reloadOnCreate=false (default)", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, createNamespace) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{}) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, createNamespace) + }) + + It("should NOT reload when a new ConfigMap is created (default behavior)", func() { + By("Creating a Deployment with annotation for a ConfigMap that doesn't exist yet") + _, err := utils.CreateDeployment(ctx, kubeClient, createNamespace, deploymentName, + utils.WithAnnotations(utils.BuildConfigMapReloadAnnotation(configMapName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, createNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Creating the ConfigMap that the Deployment references") + _, err = utils.CreateConfigMap(ctx, kubeClient, createNamespace, configMapName, + map[string]string{"key": "value"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (reloadOnCreate=false)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, createNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload on create when reloadOnCreate=false") + }) + }) +}) diff --git a/test/e2e/flags/reload_on_delete_test.go b/test/e2e/flags/reload_on_delete_test.go new file mode 100644 index 00000000..ed400e35 --- /dev/null +++ b/test/e2e/flags/reload_on_delete_test.go @@ -0,0 +1,153 @@ +package flags + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Reload On Delete Flag Tests", Serial, func() { + var ( + deploymentName string + configMapName string + deleteNamespace string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + deleteNamespace = "delete-" + utils.RandName("ns") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, deleteNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, deleteNamespace, configMapName) + }) + + Context("with reloadOnDelete=true flag", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, deleteNamespace) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.reloadOnDelete": "true", + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, deleteNamespace) + }) + + It("should reload when a referenced ConfigMap is deleted", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, deleteNamespace, configMapName, + map[string]string{"key": "value"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with annotation for the ConfigMap") + _, err = utils.CreateDeployment(ctx, kubeClient, deleteNamespace, deploymentName, + utils.WithAnnotations(utils.BuildConfigMapReloadAnnotation(configMapName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, deleteNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Deleting the ConfigMap") + err = utils.DeleteConfigMap(ctx, kubeClient, deleteNamespace, configMapName) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded (reloadOnDelete=true)") + reloaded, err := adapter.WaitReloaded(ctx, deleteNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when referenced ConfigMap is deleted") + }) + + It("should reload when a referenced Secret is deleted", func() { + secretName := utils.RandName("secret") + + By("Creating a Secret") + _, err := utils.CreateSecretFromStrings(ctx, kubeClient, deleteNamespace, secretName, + map[string]string{"password": "secret"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with annotation for the Secret") + _, err = utils.CreateDeployment(ctx, kubeClient, deleteNamespace, deploymentName, + utils.WithAnnotations(utils.BuildSecretReloadAnnotation(secretName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, deleteNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Deleting the Secret") + err = utils.DeleteSecret(ctx, kubeClient, deleteNamespace, secretName) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded (reloadOnDelete=true)") + reloaded, err := adapter.WaitReloaded(ctx, deleteNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should reload when referenced Secret is deleted") + }) + }) + + Context("with reloadOnDelete=false (default)", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, deleteNamespace) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{}) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, deleteNamespace) + }) + + It("should NOT reload when a referenced ConfigMap is deleted (default behavior)", func() { + By("Creating a ConfigMap") + _, err := utils.CreateConfigMap(ctx, kubeClient, deleteNamespace, configMapName, + map[string]string{"key": "value"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with annotation for the ConfigMap") + _, err = utils.CreateDeployment(ctx, kubeClient, deleteNamespace, deploymentName, + utils.WithAnnotations(utils.BuildConfigMapReloadAnnotation(configMapName)), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, deleteNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Deleting the ConfigMap") + err = utils.DeleteConfigMap(ctx, kubeClient, deleteNamespace, configMapName) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (reloadOnDelete=false)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, deleteNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload on delete when reloadOnDelete=false") + }) + }) +}) diff --git a/test/e2e/flags/resource_selector_test.go b/test/e2e/flags/resource_selector_test.go new file mode 100644 index 00000000..cc94612a --- /dev/null +++ b/test/e2e/flags/resource_selector_test.go @@ -0,0 +1,112 @@ +package flags + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Resource Label Selector Flag Tests", Serial, func() { + var ( + deploymentName string + matchingCM string + nonMatchingCM string + resourceNS string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + matchingCM = utils.RandName("match-cm") + nonMatchingCM = utils.RandName("nomatch-cm") + resourceNS = "resource-" + utils.RandName("ns") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, resourceNS, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, resourceNS, matchingCM) + _ = utils.DeleteConfigMap(ctx, kubeClient, resourceNS, nonMatchingCM) + }) + + Context("with resourceLabelSelector flag", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, resourceNS) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.resourceLabelSelector": "reload=true", + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, resourceNS) + }) + + It("should reload when labeled ConfigMap changes", func() { + By("Creating a ConfigMap with matching label") + _, err := utils.CreateConfigMapWithLabels(ctx, kubeClient, resourceNS, matchingCM, + map[string]string{"key": "initial"}, + map[string]string{"reload": "true"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, resourceNS, deploymentName, + utils.WithConfigMapEnvFrom(matchingCM), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, resourceNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the labeled ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, resourceNS, matchingCM, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded") + reloaded, err := adapter.WaitReloaded(ctx, resourceNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment should be reloaded when labeled ConfigMap changes") + }) + + It("should NOT reload when unlabeled ConfigMap changes", func() { + By("Creating a ConfigMap WITHOUT matching label") + _, err := utils.CreateConfigMap(ctx, kubeClient, resourceNS, nonMatchingCM, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment with auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, resourceNS, deploymentName, + utils.WithConfigMapEnvFrom(nonMatchingCM), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, resourceNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the unlabeled ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, resourceNS, nonMatchingCM, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (unlabeled ConfigMap)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, resourceNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment should NOT reload when unlabeled ConfigMap changes") + }) + }) +}) diff --git a/test/e2e/flags/watch_globally_test.go b/test/e2e/flags/watch_globally_test.go new file mode 100644 index 00000000..96e3fb2d --- /dev/null +++ b/test/e2e/flags/watch_globally_test.go @@ -0,0 +1,164 @@ +package flags + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/stakater/Reloader/test/e2e/utils" +) + +var _ = Describe("Watch Globally Flag Tests", Serial, func() { + var ( + deploymentName string + configMapName string + otherNS string + adapter *utils.DeploymentAdapter + ) + + BeforeEach(func() { + deploymentName = utils.RandName("deploy") + configMapName = utils.RandName("cm") + otherNS = "other-" + utils.RandName("ns") + adapter = utils.NewDeploymentAdapter(kubeClient) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, testNamespace, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, testNamespace, configMapName) + _ = utils.DeleteDeployment(ctx, kubeClient, otherNS, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, otherNS, configMapName) + }) + + Context("with watchGlobally=false flag", func() { + BeforeEach(func() { + err := utils.CreateNamespace(ctx, kubeClient, otherNS) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.watchGlobally": "false", + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, otherNS) + }) + + It("should reload workloads in Reloader's namespace when watchGlobally=false", func() { + By("Creating a ConfigMap in Reloader's namespace") + _, err := utils.CreateConfigMap(ctx, kubeClient, testNamespace, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment in Reloader's namespace with auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, testNamespace, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, testNamespace, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, testNamespace, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded (same namespace should work)") + reloaded, err := adapter.WaitReloaded(ctx, testNamespace, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment in Reloader's namespace should reload with watchGlobally=false") + }) + + It("should NOT reload workloads in other namespaces when watchGlobally=false", func() { + By("Creating a ConfigMap in another namespace") + _, err := utils.CreateConfigMap(ctx, kubeClient, otherNS, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment in another namespace with auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, otherNS, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, otherNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap in the other namespace") + err = utils.UpdateConfigMap(ctx, kubeClient, otherNS, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying Deployment was NOT reloaded (different namespace with watchGlobally=false)") + time.Sleep(utils.NegativeTestWait) + reloaded, err := adapter.WaitReloaded(ctx, otherNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ShortTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeFalse(), "Deployment in other namespace should NOT reload with watchGlobally=false") + }) + }) + + Context("with watchGlobally=true flag (default)", func() { + var globalNS string + + BeforeEach(func() { + globalNS = "global-" + utils.RandName("ns") + + err := utils.CreateNamespace(ctx, kubeClient, globalNS) + Expect(err).NotTo(HaveOccurred()) + + err = deployReloaderWithFlags(map[string]string{ + "reloader.watchGlobally": "true", + }) + Expect(err).NotTo(HaveOccurred()) + + err = waitForReloaderReady() + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + _ = utils.DeleteDeployment(ctx, kubeClient, globalNS, deploymentName) + _ = utils.DeleteConfigMap(ctx, kubeClient, globalNS, configMapName) + _ = undeployReloader() + _ = utils.DeleteNamespace(ctx, kubeClient, globalNS) + }) + + It("should reload workloads in any namespace when watchGlobally=true", func() { + By("Creating a ConfigMap in a different namespace") + _, err := utils.CreateConfigMap(ctx, kubeClient, globalNS, configMapName, + map[string]string{"key": "initial"}, nil) + Expect(err).NotTo(HaveOccurred()) + + By("Creating a Deployment in a different namespace with auto annotation") + _, err = utils.CreateDeployment(ctx, kubeClient, globalNS, deploymentName, + utils.WithConfigMapEnvFrom(configMapName), + utils.WithAnnotations(utils.BuildAutoTrueAnnotation()), + ) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be ready") + err = adapter.WaitReady(ctx, globalNS, deploymentName, utils.WorkloadReadyTimeout) + Expect(err).NotTo(HaveOccurred()) + + By("Updating the ConfigMap") + err = utils.UpdateConfigMap(ctx, kubeClient, globalNS, configMapName, map[string]string{"key": "updated"}) + Expect(err).NotTo(HaveOccurred()) + + By("Waiting for Deployment to be reloaded (watchGlobally=true)") + reloaded, err := adapter.WaitReloaded(ctx, globalNS, deploymentName, + utils.AnnotationLastReloadedFrom, utils.ReloadTimeout) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded).To(BeTrue(), "Deployment in any namespace should reload with watchGlobally=true") + }) + }) +}) diff --git a/test/e2e/utils/accessors.go b/test/e2e/utils/accessors.go new file mode 100644 index 00000000..445f86a9 --- /dev/null +++ b/test/e2e/utils/accessors.go @@ -0,0 +1,176 @@ +package utils + +import ( + "strings" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" + + rolloutsv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" + openshiftappsv1 "github.com/openshift/api/apps/v1" +) + +// Deployment accessors +var ( + DeploymentPodTemplate PodTemplateAccessor[*appsv1.Deployment] = func(d *appsv1.Deployment) *corev1.PodTemplateSpec { + return &d.Spec.Template + } + DeploymentAnnotations AnnotationAccessor[*appsv1.Deployment] = func(d *appsv1.Deployment) map[string]string { + return d.Annotations + } + DeploymentContainers ContainerAccessor[*appsv1.Deployment] = func(d *appsv1.Deployment) []corev1.Container { + return d.Spec.Template.Spec.Containers + } + DeploymentIsReady StatusAccessor[*appsv1.Deployment] = func(d *appsv1.Deployment) bool { + if d.Spec.Replicas == nil { + return false + } + return d.Status.ObservedGeneration >= d.Generation && + d.Status.ReadyReplicas == *d.Spec.Replicas && + d.Status.UpdatedReplicas == *d.Spec.Replicas && + d.Status.AvailableReplicas == *d.Spec.Replicas + } +) + +// DaemonSet accessors +var ( + DaemonSetPodTemplate PodTemplateAccessor[*appsv1.DaemonSet] = func(d *appsv1.DaemonSet) *corev1.PodTemplateSpec { + return &d.Spec.Template + } + DaemonSetAnnotations AnnotationAccessor[*appsv1.DaemonSet] = func(d *appsv1.DaemonSet) map[string]string { + return d.Annotations + } + DaemonSetContainers ContainerAccessor[*appsv1.DaemonSet] = func(d *appsv1.DaemonSet) []corev1.Container { + return d.Spec.Template.Spec.Containers + } + DaemonSetIsReady StatusAccessor[*appsv1.DaemonSet] = func(d *appsv1.DaemonSet) bool { + return d.Status.ObservedGeneration >= d.Generation && + d.Status.DesiredNumberScheduled > 0 && + d.Status.NumberReady == d.Status.DesiredNumberScheduled && + d.Status.UpdatedNumberScheduled == d.Status.DesiredNumberScheduled + } +) + +// StatefulSet accessors +var ( + StatefulSetPodTemplate PodTemplateAccessor[*appsv1.StatefulSet] = func(s *appsv1.StatefulSet) *corev1.PodTemplateSpec { + return &s.Spec.Template + } + StatefulSetAnnotations AnnotationAccessor[*appsv1.StatefulSet] = func(s *appsv1.StatefulSet) map[string]string { + return s.Annotations + } + StatefulSetContainers ContainerAccessor[*appsv1.StatefulSet] = func(s *appsv1.StatefulSet) []corev1.Container { + return s.Spec.Template.Spec.Containers + } + StatefulSetIsReady StatusAccessor[*appsv1.StatefulSet] = func(s *appsv1.StatefulSet) bool { + if s.Spec.Replicas == nil { + return false + } + return s.Status.ObservedGeneration >= s.Generation && + s.Status.ReadyReplicas == *s.Spec.Replicas && + s.Status.UpdatedReplicas == *s.Spec.Replicas + } +) + +// Job accessors +var ( + JobPodTemplate PodTemplateAccessor[*batchv1.Job] = func(j *batchv1.Job) *corev1.PodTemplateSpec { + return &j.Spec.Template + } + JobAnnotations AnnotationAccessor[*batchv1.Job] = func(j *batchv1.Job) map[string]string { + return j.Annotations + } + JobContainers ContainerAccessor[*batchv1.Job] = func(j *batchv1.Job) []corev1.Container { + return j.Spec.Template.Spec.Containers + } + JobIsReady StatusAccessor[*batchv1.Job] = func(j *batchv1.Job) bool { + return j.Status.Active > 0 || j.Status.Succeeded > 0 + } + JobUID UIDAccessor[*batchv1.Job] = func(j *batchv1.Job) types.UID { + return j.UID + } +) + +// CronJob accessors +var ( + CronJobPodTemplate PodTemplateAccessor[*batchv1.CronJob] = func(c *batchv1.CronJob) *corev1.PodTemplateSpec { + return &c.Spec.JobTemplate.Spec.Template + } + CronJobAnnotations AnnotationAccessor[*batchv1.CronJob] = func(c *batchv1.CronJob) map[string]string { + return c.Annotations + } + CronJobContainers ContainerAccessor[*batchv1.CronJob] = func(c *batchv1.CronJob) []corev1.Container { + return c.Spec.JobTemplate.Spec.Template.Spec.Containers + } + CronJobExists StatusAccessor[*batchv1.CronJob] = func(c *batchv1.CronJob) bool { + return true + } +) + +// Argo Rollout accessors +var ( + RolloutPodTemplate PodTemplateAccessor[*rolloutsv1alpha1.Rollout] = func(r *rolloutsv1alpha1.Rollout) *corev1.PodTemplateSpec { + return &r.Spec.Template + } + RolloutAnnotations AnnotationAccessor[*rolloutsv1alpha1.Rollout] = func(r *rolloutsv1alpha1.Rollout) map[string]string { + return r.Annotations + } + RolloutContainers ContainerAccessor[*rolloutsv1alpha1.Rollout] = func(r *rolloutsv1alpha1.Rollout) []corev1.Container { + return r.Spec.Template.Spec.Containers + } + RolloutIsReady StatusAccessor[*rolloutsv1alpha1.Rollout] = func(r *rolloutsv1alpha1.Rollout) bool { + if r.Spec.Replicas == nil { + return false + } + return r.Status.ReadyReplicas == *r.Spec.Replicas + } + RolloutHasRestartAt StatusAccessor[*rolloutsv1alpha1.Rollout] = func(r *rolloutsv1alpha1.Rollout) bool { + return r.Spec.RestartAt != nil + } +) + +// OpenShift DeploymentConfig accessors +var ( + DeploymentConfigPodTemplate PodTemplateAccessor[*openshiftappsv1.DeploymentConfig] = func(d *openshiftappsv1.DeploymentConfig) *corev1.PodTemplateSpec { + return d.Spec.Template + } + DeploymentConfigAnnotations AnnotationAccessor[*openshiftappsv1.DeploymentConfig] = func(d *openshiftappsv1.DeploymentConfig) map[string]string { + return d.Annotations + } + DeploymentConfigContainers ContainerAccessor[*openshiftappsv1.DeploymentConfig] = func(d *openshiftappsv1.DeploymentConfig) []corev1.Container { + if d.Spec.Template == nil { + return nil + } + return d.Spec.Template.Spec.Containers + } + DeploymentConfigIsReady StatusAccessor[*openshiftappsv1.DeploymentConfig] = func(d *openshiftappsv1.DeploymentConfig) bool { + return d.Status.ReadyReplicas == d.Spec.Replicas + } +) + +// SecretProviderClassPodStatus accessors +var ( + SPCPSIsMounted StatusAccessor[*csiv1.SecretProviderClassPodStatus] = func(s *csiv1.SecretProviderClassPodStatus) bool { + return s.Status.Mounted + } + SPCPSClassName ValueAccessor[*csiv1.SecretProviderClassPodStatus, string] = func(s *csiv1.SecretProviderClassPodStatus) string { + return s.Status.SecretProviderClassName + } + SPCPSPodName ValueAccessor[*csiv1.SecretProviderClassPodStatus, string] = func(s *csiv1.SecretProviderClassPodStatus) string { + return s.Status.PodName + } + // SPCPSVersions returns concatenated versions of all objects for change detection. + SPCPSVersions ValueAccessor[*csiv1.SecretProviderClassPodStatus, string] = func(s *csiv1.SecretProviderClassPodStatus) string { + if len(s.Status.Objects) == 0 { + return "" + } + var versions []string + for _, obj := range s.Status.Objects { + versions = append(versions, obj.Version) + } + return strings.Join(versions, ",") + } +) diff --git a/test/e2e/utils/annotations.go b/test/e2e/utils/annotations.go new file mode 100644 index 00000000..60c0132b --- /dev/null +++ b/test/e2e/utils/annotations.go @@ -0,0 +1,241 @@ +package utils + +// Annotation key constants used by Reloader. +// These follow the pattern: {scope}.reloader.stakater.com/{action} +// where scope can be empty (all resources), "configmap", "secret", "deployment", etc. +const ( + // ============================================================ + // Core reload annotations + // ============================================================ + + // AnnotationLastReloadedFrom is set by Reloader on workloads to track the last resource + // that triggered a reload. Format: "{namespace}/{resource-type}/{resource-name}" + AnnotationLastReloadedFrom = "reloader.stakater.com/last-reloaded-from" + + // AnnotationConfigMapReload triggers reload when specified ConfigMap(s) change. + // Value: comma-separated list of ConfigMap names, e.g., "config1,config2" + AnnotationConfigMapReload = "configmap.reloader.stakater.com/reload" + + // AnnotationSecretReload triggers reload when specified Secret(s) change. + // Value: comma-separated list of Secret names, e.g., "secret1,secret2" + AnnotationSecretReload = "secret.reloader.stakater.com/reload" + + // AnnotationSecretProviderClassReload triggers reload when specified SecretProviderClass(es) change. + // Value: comma-separated list of SecretProviderClass names, e.g., "spc1,spc2" + // Note: Reloader actually watches SecretProviderClassPodStatus resources, not SecretProviderClass. + AnnotationSecretProviderClassReload = "secretproviderclass.reloader.stakater.com/reload" + + // ============================================================ + // Auto-reload annotations + // ============================================================ + + // AnnotationAuto enables auto-reload for all referenced ConfigMaps and Secrets. + // Value: "true" or "false" + AnnotationAuto = "reloader.stakater.com/auto" + + // AnnotationConfigMapAuto enables auto-reload for all referenced ConfigMaps only. + // Value: "true" or "false" + AnnotationConfigMapAuto = "configmap.reloader.stakater.com/auto" + + // AnnotationSecretAuto enables auto-reload for all referenced Secrets only. + // Value: "true" or "false" + AnnotationSecretAuto = "secret.reloader.stakater.com/auto" + + // AnnotationSecretProviderClassAuto enables auto-reload for all referenced SecretProviderClasses only. + // Value: "true" or "false" + AnnotationSecretProviderClassAuto = "secretproviderclass.reloader.stakater.com/auto" + + // ============================================================ + // Exclude annotations (used with auto=true to exclude specific resources) + // ============================================================ + + // AnnotationConfigMapExclude excludes specified ConfigMaps from auto-reload. + // Value: comma-separated list of ConfigMap names + AnnotationConfigMapExclude = "configmaps.exclude.reloader.stakater.com/reload" + + // AnnotationSecretExclude excludes specified Secrets from auto-reload. + // Value: comma-separated list of Secret names + AnnotationSecretExclude = "secrets.exclude.reloader.stakater.com/reload" + + // AnnotationSecretProviderClassExclude excludes specified SecretProviderClasses from auto-reload. + // Value: comma-separated list of SecretProviderClass names + AnnotationSecretProviderClassExclude = "secretproviderclasses.exclude.reloader.stakater.com/reload" + + // ============================================================ + // Search annotations (for regex matching) + // ============================================================ + + // AnnotationSearch enables regex search mode for ConfigMap/Secret names. + // Value: "true" + // Used with reload annotation where value is a regex pattern. + AnnotationSearch = "reloader.stakater.com/search" + + // AnnotationMatch is an alias for AnnotationSearch. + // Value: "true" + AnnotationMatch = "reloader.stakater.com/match" + + // ============================================================ + // Resource-level annotations (placed on ConfigMap/Secret) + // ============================================================ + + // AnnotationIgnore prevents Reloader from triggering reloads for this resource. + // Place this on a ConfigMap or Secret to exclude it from reload triggers. + // Value: "true" + AnnotationIgnore = "reloader.stakater.com/ignore" + + // ============================================================ + // Pause/period annotations + // ============================================================ + + // AnnotationDeploymentPausePeriod sets a pause period before triggering reload. + // Value: duration string, e.g., "10s", "1m" + AnnotationDeploymentPausePeriod = "deployment.reloader.stakater.com/pause-period" + + // AnnotationDeploymentPausedAt is set by Reloader when a workload is paused. + // Value: RFC3339 timestamp + AnnotationDeploymentPausedAt = "deployment.reloader.stakater.com/paused-at" + + // ============================================================ + // Argo Rollouts specific annotations + // ============================================================ + + // AnnotationRolloutStrategy specifies the strategy for Argo Rollouts. + // Value: "restart" (sets spec.restartAt) + AnnotationRolloutStrategy = "reloader.stakater.com/rollout-strategy" +) + +// Annotation values. +const ( + // AnnotationValueTrue is the string "true" for annotation values. + AnnotationValueTrue = "true" + + // AnnotationValueFalse is the string "false" for annotation values. + AnnotationValueFalse = "false" + + // AnnotationValueRestart is the "restart" strategy value for Argo Rollouts. + AnnotationValueRestart = "restart" +) + +// BuildConfigMapReloadAnnotation creates an annotation map for ConfigMap reload. +func BuildConfigMapReloadAnnotation(configMapNames ...string) map[string]string { + return map[string]string{ + AnnotationConfigMapReload: joinNames(configMapNames), + } +} + +// BuildSecretReloadAnnotation creates an annotation map for Secret reload. +func BuildSecretReloadAnnotation(secretNames ...string) map[string]string { + return map[string]string{ + AnnotationSecretReload: joinNames(secretNames), + } +} + +// BuildSecretProviderClassReloadAnnotation creates an annotation map for SecretProviderClass reload. +func BuildSecretProviderClassReloadAnnotation(spcNames ...string) map[string]string { + return map[string]string{ + AnnotationSecretProviderClassReload: joinNames(spcNames), + } +} + +// BuildAutoTrueAnnotation creates an annotation map with auto=true. +func BuildAutoTrueAnnotation() map[string]string { + return map[string]string{ + AnnotationAuto: AnnotationValueTrue, + } +} + +// BuildAutoFalseAnnotation creates an annotation map with auto=false. +func BuildAutoFalseAnnotation() map[string]string { + return map[string]string{ + AnnotationAuto: AnnotationValueFalse, + } +} + +// BuildConfigMapAutoAnnotation creates an annotation map with configmap auto=true. +func BuildConfigMapAutoAnnotation() map[string]string { + return map[string]string{ + AnnotationConfigMapAuto: AnnotationValueTrue, + } +} + +// BuildSecretAutoAnnotation creates an annotation map with secret auto=true. +func BuildSecretAutoAnnotation() map[string]string { + return map[string]string{ + AnnotationSecretAuto: AnnotationValueTrue, + } +} + +// BuildSecretProviderClassAutoAnnotation creates an annotation map with secretproviderclass auto=true. +func BuildSecretProviderClassAutoAnnotation() map[string]string { + return map[string]string{ + AnnotationSecretProviderClassAuto: AnnotationValueTrue, + } +} + +// BuildSearchAnnotation creates an annotation map to enable search mode. +func BuildSearchAnnotation() map[string]string { + return map[string]string{ + AnnotationSearch: AnnotationValueTrue, + } +} + +// BuildMatchAnnotation creates an annotation map to enable match mode. +func BuildMatchAnnotation() map[string]string { + return map[string]string{ + AnnotationMatch: AnnotationValueTrue, + } +} + +// BuildIgnoreAnnotation creates an annotation map to ignore a resource. +func BuildIgnoreAnnotation() map[string]string { + return map[string]string{ + AnnotationIgnore: AnnotationValueTrue, + } +} + +// BuildRolloutRestartStrategyAnnotation creates an annotation for Argo Rollout restart strategy. +func BuildRolloutRestartStrategyAnnotation() map[string]string { + return map[string]string{ + AnnotationRolloutStrategy: AnnotationValueRestart, + } +} + +// BuildConfigMapExcludeAnnotation creates an annotation to exclude ConfigMaps from auto-reload. +func BuildConfigMapExcludeAnnotation(configMapNames ...string) map[string]string { + return map[string]string{ + AnnotationConfigMapExclude: joinNames(configMapNames), + } +} + +// BuildSecretExcludeAnnotation creates an annotation to exclude Secrets from auto-reload. +func BuildSecretExcludeAnnotation(secretNames ...string) map[string]string { + return map[string]string{ + AnnotationSecretExclude: joinNames(secretNames), + } +} + +// BuildSecretProviderClassExcludeAnnotation creates an annotation to exclude SecretProviderClasses from auto-reload. +func BuildSecretProviderClassExcludeAnnotation(spcNames ...string) map[string]string { + return map[string]string{ + AnnotationSecretProviderClassExclude: joinNames(spcNames), + } +} + +// BuildPausePeriodAnnotation creates an annotation for deployment pause period. +func BuildPausePeriodAnnotation(duration string) map[string]string { + return map[string]string{ + AnnotationDeploymentPausePeriod: duration, + } +} + +// joinNames joins names with comma separator. +func joinNames(names []string) string { + if len(names) == 0 { + return "" + } + result := names[0] + for i := 1; i < len(names); i++ { + result += "," + names[i] + } + return result +} diff --git a/test/e2e/utils/annotations_test.go b/test/e2e/utils/annotations_test.go new file mode 100644 index 00000000..fa0d699c --- /dev/null +++ b/test/e2e/utils/annotations_test.go @@ -0,0 +1,303 @@ +package utils + +import ( + "testing" +) + +func TestBuildConfigMapReloadAnnotation(t *testing.T) { + tests := []struct { + name string + configMaps []string + expected map[string]string + }{ + { + name: "single ConfigMap", + configMaps: []string{"my-config"}, + expected: map[string]string{ + AnnotationConfigMapReload: "my-config", + }, + }, + { + name: "multiple ConfigMaps", + configMaps: []string{"config1", "config2", "config3"}, + expected: map[string]string{ + AnnotationConfigMapReload: "config1,config2,config3", + }, + }, + { + name: "empty list", + configMaps: []string{}, + expected: map[string]string{ + AnnotationConfigMapReload: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BuildConfigMapReloadAnnotation(tt.configMaps...) + if len(result) != len(tt.expected) { + t.Errorf("BuildConfigMapReloadAnnotation() returned %d entries, want %d", len(result), len(tt.expected)) + } + for k, v := range tt.expected { + if result[k] != v { + t.Errorf("BuildConfigMapReloadAnnotation()[%q] = %q, want %q", k, result[k], v) + } + } + }) + } +} + +func TestBuildSecretReloadAnnotation(t *testing.T) { + tests := []struct { + name string + secrets []string + expected map[string]string + }{ + { + name: "single Secret", + secrets: []string{"my-secret"}, + expected: map[string]string{ + AnnotationSecretReload: "my-secret", + }, + }, + { + name: "multiple Secrets", + secrets: []string{"secret1", "secret2"}, + expected: map[string]string{ + AnnotationSecretReload: "secret1,secret2", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BuildSecretReloadAnnotation(tt.secrets...) + for k, v := range tt.expected { + if result[k] != v { + t.Errorf("BuildSecretReloadAnnotation()[%q] = %q, want %q", k, result[k], v) + } + } + }) + } +} + +func TestBuildAutoAnnotations(t *testing.T) { + t.Run("BuildAutoTrueAnnotation", func(t *testing.T) { + result := BuildAutoTrueAnnotation() + if result[AnnotationAuto] != AnnotationValueTrue { + t.Errorf("BuildAutoTrueAnnotation()[%q] = %q, want %q", + AnnotationAuto, result[AnnotationAuto], AnnotationValueTrue) + } + }) + + t.Run("BuildAutoFalseAnnotation", func(t *testing.T) { + result := BuildAutoFalseAnnotation() + if result[AnnotationAuto] != AnnotationValueFalse { + t.Errorf("BuildAutoFalseAnnotation()[%q] = %q, want %q", + AnnotationAuto, result[AnnotationAuto], AnnotationValueFalse) + } + }) + + t.Run("BuildConfigMapAutoAnnotation", func(t *testing.T) { + result := BuildConfigMapAutoAnnotation() + if result[AnnotationConfigMapAuto] != AnnotationValueTrue { + t.Errorf("BuildConfigMapAutoAnnotation()[%q] = %q, want %q", + AnnotationConfigMapAuto, result[AnnotationConfigMapAuto], AnnotationValueTrue) + } + }) + + t.Run("BuildSecretAutoAnnotation", func(t *testing.T) { + result := BuildSecretAutoAnnotation() + if result[AnnotationSecretAuto] != AnnotationValueTrue { + t.Errorf("BuildSecretAutoAnnotation()[%q] = %q, want %q", + AnnotationSecretAuto, result[AnnotationSecretAuto], AnnotationValueTrue) + } + }) +} + +func TestBuildSearchMatchAnnotations(t *testing.T) { + t.Run("BuildSearchAnnotation", func(t *testing.T) { + result := BuildSearchAnnotation() + if result[AnnotationSearch] != AnnotationValueTrue { + t.Errorf("BuildSearchAnnotation()[%q] = %q, want %q", + AnnotationSearch, result[AnnotationSearch], AnnotationValueTrue) + } + }) + + t.Run("BuildMatchAnnotation", func(t *testing.T) { + result := BuildMatchAnnotation() + if result[AnnotationMatch] != AnnotationValueTrue { + t.Errorf("BuildMatchAnnotation()[%q] = %q, want %q", + AnnotationMatch, result[AnnotationMatch], AnnotationValueTrue) + } + }) +} + +func TestBuildIgnoreAnnotation(t *testing.T) { + result := BuildIgnoreAnnotation() + if result[AnnotationIgnore] != AnnotationValueTrue { + t.Errorf("BuildIgnoreAnnotation()[%q] = %q, want %q", + AnnotationIgnore, result[AnnotationIgnore], AnnotationValueTrue) + } +} + +func TestBuildRolloutRestartStrategyAnnotation(t *testing.T) { + result := BuildRolloutRestartStrategyAnnotation() + if result[AnnotationRolloutStrategy] != AnnotationValueRestart { + t.Errorf("BuildRolloutRestartStrategyAnnotation()[%q] = %q, want %q", + AnnotationRolloutStrategy, result[AnnotationRolloutStrategy], AnnotationValueRestart) + } +} + +func TestBuildExcludeAnnotations(t *testing.T) { + t.Run("BuildConfigMapExcludeAnnotation single", func(t *testing.T) { + result := BuildConfigMapExcludeAnnotation("excluded-cm") + if result[AnnotationConfigMapExclude] != "excluded-cm" { + t.Errorf("BuildConfigMapExcludeAnnotation()[%q] = %q, want %q", + AnnotationConfigMapExclude, result[AnnotationConfigMapExclude], "excluded-cm") + } + }) + + t.Run("BuildConfigMapExcludeAnnotation multiple", func(t *testing.T) { + result := BuildConfigMapExcludeAnnotation("cm1", "cm2", "cm3") + expected := "cm1,cm2,cm3" + if result[AnnotationConfigMapExclude] != expected { + t.Errorf("BuildConfigMapExcludeAnnotation()[%q] = %q, want %q", + AnnotationConfigMapExclude, result[AnnotationConfigMapExclude], expected) + } + }) + + t.Run("BuildSecretExcludeAnnotation single", func(t *testing.T) { + result := BuildSecretExcludeAnnotation("excluded-secret") + if result[AnnotationSecretExclude] != "excluded-secret" { + t.Errorf("BuildSecretExcludeAnnotation()[%q] = %q, want %q", + AnnotationSecretExclude, result[AnnotationSecretExclude], "excluded-secret") + } + }) + + t.Run("BuildSecretExcludeAnnotation multiple", func(t *testing.T) { + result := BuildSecretExcludeAnnotation("s1", "s2") + expected := "s1,s2" + if result[AnnotationSecretExclude] != expected { + t.Errorf("BuildSecretExcludeAnnotation()[%q] = %q, want %q", + AnnotationSecretExclude, result[AnnotationSecretExclude], expected) + } + }) +} + +func TestBuildPausePeriodAnnotation(t *testing.T) { + tests := []struct { + name string + duration string + expected string + }{ + { + name: "10 seconds", + duration: "10s", + expected: "10s", + }, + { + name: "1 minute", + duration: "1m", + expected: "1m", + }, + { + name: "30 minutes", + duration: "30m", + expected: "30m", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BuildPausePeriodAnnotation(tt.duration) + if result[AnnotationDeploymentPausePeriod] != tt.expected { + t.Errorf("BuildPausePeriodAnnotation(%q)[%q] = %q, want %q", + tt.duration, AnnotationDeploymentPausePeriod, + result[AnnotationDeploymentPausePeriod], tt.expected) + } + }) + } +} + +func TestJoinNames(t *testing.T) { + tests := []struct { + name string + names []string + expected string + }{ + { + name: "empty slice", + names: []string{}, + expected: "", + }, + { + name: "single name", + names: []string{"one"}, + expected: "one", + }, + { + name: "two names", + names: []string{"one", "two"}, + expected: "one,two", + }, + { + name: "three names", + names: []string{"alpha", "beta", "gamma"}, + expected: "alpha,beta,gamma", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := joinNames(tt.names) + if result != tt.expected { + t.Errorf("joinNames(%v) = %q, want %q", tt.names, result, tt.expected) + } + }) + } +} + +func TestAnnotationConstants(t *testing.T) { + tests := []struct { + name string + constant string + expected string + }{ + {"AnnotationLastReloadedFrom", AnnotationLastReloadedFrom, "reloader.stakater.com/last-reloaded-from"}, + {"AnnotationConfigMapReload", AnnotationConfigMapReload, "configmap.reloader.stakater.com/reload"}, + {"AnnotationSecretReload", AnnotationSecretReload, "secret.reloader.stakater.com/reload"}, + {"AnnotationAuto", AnnotationAuto, "reloader.stakater.com/auto"}, + {"AnnotationConfigMapAuto", AnnotationConfigMapAuto, "configmap.reloader.stakater.com/auto"}, + {"AnnotationSecretAuto", AnnotationSecretAuto, "secret.reloader.stakater.com/auto"}, + {"AnnotationConfigMapExclude", AnnotationConfigMapExclude, "configmaps.exclude.reloader.stakater.com/reload"}, + {"AnnotationSecretExclude", AnnotationSecretExclude, "secrets.exclude.reloader.stakater.com/reload"}, + {"AnnotationSearch", AnnotationSearch, "reloader.stakater.com/search"}, + {"AnnotationMatch", AnnotationMatch, "reloader.stakater.com/match"}, + {"AnnotationIgnore", AnnotationIgnore, "reloader.stakater.com/ignore"}, + {"AnnotationDeploymentPausePeriod", AnnotationDeploymentPausePeriod, "deployment.reloader.stakater.com/pause-period"}, + {"AnnotationDeploymentPausedAt", AnnotationDeploymentPausedAt, "deployment.reloader.stakater.com/paused-at"}, + {"AnnotationRolloutStrategy", AnnotationRolloutStrategy, "reloader.stakater.com/rollout-strategy"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.constant != tt.expected { + t.Errorf("%s = %q, want %q", tt.name, tt.constant, tt.expected) + } + }) + } +} + +func TestAnnotationValues(t *testing.T) { + if AnnotationValueTrue != "true" { + t.Errorf("AnnotationValueTrue = %q, want \"true\"", AnnotationValueTrue) + } + if AnnotationValueFalse != "false" { + t.Errorf("AnnotationValueFalse = %q, want \"false\"", AnnotationValueFalse) + } + if AnnotationValueRestart != "restart" { + t.Errorf("AnnotationValueRestart = %q, want \"restart\"", AnnotationValueRestart) + } +} diff --git a/test/e2e/utils/argo.go b/test/e2e/utils/argo.go new file mode 100644 index 00000000..b06da6c4 --- /dev/null +++ b/test/e2e/utils/argo.go @@ -0,0 +1,120 @@ +package utils + +import ( + "context" + + rolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" + rolloutsclient "github.com/argoproj/argo-rollouts/pkg/client/clientset/versioned" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +// RolloutOption is a function that modifies a Rollout. +type RolloutOption func(*rolloutv1alpha1.Rollout) + +// IsArgoRolloutsInstalled checks if Argo Rollouts CRD is installed in the cluster. +func IsArgoRolloutsInstalled(ctx context.Context, client rolloutsclient.Interface) bool { + if client == nil { + return false + } + _, err := client.ArgoprojV1alpha1().Rollouts("default").List(ctx, metav1.ListOptions{Limit: 1}) + return err == nil +} + +// CreateRollout creates an Argo Rollout with the given options. +func CreateRollout(ctx context.Context, client rolloutsclient.Interface, namespace, name string, opts ...RolloutOption) (*rolloutv1alpha1.Rollout, error) { + rollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: rolloutv1alpha1.RolloutSpec{ + Replicas: ptr.To[int32](1), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "main", + Image: DefaultImage, + Command: []string{"sh", "-c", DefaultCommand}, + }}, + }, + }, + Strategy: rolloutv1alpha1.RolloutStrategy{ + Canary: &rolloutv1alpha1.CanaryStrategy{ + Steps: []rolloutv1alpha1.CanaryStep{ + {SetWeight: ptr.To[int32](100)}, + }, + }, + }, + }, + } + + for _, opt := range opts { + opt(rollout) + } + + return client.ArgoprojV1alpha1().Rollouts(namespace).Create(ctx, rollout, metav1.CreateOptions{}) +} + +// DeleteRollout deletes an Argo Rollout using typed client. +func DeleteRollout(ctx context.Context, client rolloutsclient.Interface, namespace, name string) error { + return client.ArgoprojV1alpha1().Rollouts(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// WithRolloutConfigMapEnvFrom adds a ConfigMap envFrom to the Rollout. +func WithRolloutConfigMapEnvFrom(configMapName string) RolloutOption { + return func(r *rolloutv1alpha1.Rollout) { + AddEnvFromSource(&r.Spec.Template.Spec, 0, configMapName, false) + } +} + +// WithRolloutSecretEnvFrom adds a Secret envFrom to the Rollout. +func WithRolloutSecretEnvFrom(secretName string) RolloutOption { + return func(r *rolloutv1alpha1.Rollout) { + AddEnvFromSource(&r.Spec.Template.Spec, 0, secretName, true) + } +} + +// WithRolloutConfigMapVolume adds a ConfigMap volume to the Rollout. +func WithRolloutConfigMapVolume(configMapName string) RolloutOption { + return func(r *rolloutv1alpha1.Rollout) { + AddConfigMapVolume(&r.Spec.Template.Spec, 0, configMapName) + } +} + +// WithRolloutSecretVolume adds a Secret volume to the Rollout. +func WithRolloutSecretVolume(secretName string) RolloutOption { + return func(r *rolloutv1alpha1.Rollout) { + AddSecretVolume(&r.Spec.Template.Spec, 0, secretName) + } +} + +// WithRolloutAnnotations adds annotations to the Rollout level (where Reloader checks them). +func WithRolloutAnnotations(annotations map[string]string) RolloutOption { + return func(r *rolloutv1alpha1.Rollout) { + if len(annotations) > 0 { + if r.Annotations == nil { + r.Annotations = make(map[string]string) + } + for k, v := range annotations { + r.Annotations[k] = v + } + } + } +} + +// WithRolloutObjectAnnotations adds annotations to the Rollout's top-level metadata. +func WithRolloutObjectAnnotations(annotations map[string]string) RolloutOption { + return func(r *rolloutv1alpha1.Rollout) { + if r.Annotations == nil { + r.Annotations = make(map[string]string) + } + for k, v := range annotations { + r.Annotations[k] = v + } + } +} diff --git a/test/e2e/utils/conditions.go b/test/e2e/utils/conditions.go new file mode 100644 index 00000000..5736b022 --- /dev/null +++ b/test/e2e/utils/conditions.go @@ -0,0 +1,258 @@ +package utils + +import ( + "strings" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" +) + +// PodTemplateAccessor extracts PodTemplateSpec from a workload. +type PodTemplateAccessor[T any] func(T) *corev1.PodTemplateSpec + +// AnnotationAccessor extracts annotations from a resource. +type AnnotationAccessor[T any] func(T) map[string]string + +// ContainerAccessor extracts containers from a resource. +type ContainerAccessor[T any] func(T) []corev1.Container + +// StatusAccessor extracts ready status from a resource. +type StatusAccessor[T any] func(T) bool + +// UIDAccessor extracts UID from a resource. +type UIDAccessor[T any] func(T) types.UID + +// ValueAccessor extracts a comparable value from a resource. +type ValueAccessor[T any, V comparable] func(T) V + +// HasPodTemplateAnnotation returns a condition that checks for an annotation on the pod template. +func HasPodTemplateAnnotation[T any](accessor PodTemplateAccessor[T], key string) Condition[T] { + return func(obj T) bool { + template := accessor(obj) + if template == nil || template.Annotations == nil { + return false + } + _, ok := template.Annotations[key] + return ok + } +} + +// HasPodTemplateAnnotationChanged returns a condition that checks the pod template annotation +// is present AND its value differs from priorValue. If priorValue is empty, any non-empty value +// satisfies the condition (equivalent to HasPodTemplateAnnotation). +// Use this in WaitReloaded to correctly detect a reload after a prior reload has already set the annotation. +func HasPodTemplateAnnotationChanged[T any](accessor PodTemplateAccessor[T], key, priorValue string) Condition[T] { + return func(obj T) bool { + template := accessor(obj) + if template == nil || template.Annotations == nil { + return false + } + v, ok := template.Annotations[key] + if !ok { + return false + } + if priorValue == "" { + return true + } + return v != priorValue + } +} + +// HasAnnotation returns a condition that checks for an annotation on the resource. +func HasAnnotation[T any](accessor AnnotationAccessor[T], key string) Condition[T] { + return func(obj T) bool { + annotations := accessor(obj) + if annotations == nil { + return false + } + _, ok := annotations[key] + return ok + } +} + +// NoAnnotation returns a condition that checks an annotation is absent. +func NoAnnotation[T any](accessor AnnotationAccessor[T], key string) Condition[T] { + return func(obj T) bool { + annotations := accessor(obj) + if annotations == nil { + return true + } + _, ok := annotations[key] + return !ok + } +} + +// HasEnvVarPrefix returns a condition that checks for an env var with the given prefix. +func HasEnvVarPrefix[T any](accessor ContainerAccessor[T], prefix string) Condition[T] { + return func(obj T) bool { + containers := accessor(obj) + for _, container := range containers { + for _, env := range container.Env { + if strings.HasPrefix(env.Name, prefix) { + return true + } + } + } + return false + } +} + +// HasEnvVarNamed returns a condition that checks for an env var with exactly the given name. +func HasEnvVarNamed[T any](accessor ContainerAccessor[T], name string) Condition[T] { + return func(obj T) bool { + containers := accessor(obj) + for _, container := range containers { + for _, env := range container.Env { + if env.Name == name { + return true + } + } + } + return false + } +} + +// HasEnvVarPrefixChanged returns a condition that checks for an env var with the given prefix +// whose value has changed from priorValue. If priorValue is empty, any matching env var satisfies +// the condition (equivalent to HasEnvVarPrefix). +// Use this in WaitEnvVar to correctly detect a reload after a prior reload already set the env var. +func HasEnvVarPrefixChanged[T any](accessor ContainerAccessor[T], prefix, priorValue string) Condition[T] { + return func(obj T) bool { + containers := accessor(obj) + for _, container := range containers { + for _, env := range container.Env { + if strings.HasPrefix(env.Name, prefix) { + if priorValue == "" { + return true + } + return env.Value != priorValue + } + } + } + return false + } +} + +// GetEnvVarValueByPrefix returns the value of the first env var with the given prefix +// found across the given containers. Returns empty string if not found. +func GetEnvVarValueByPrefix(containers []corev1.Container, prefix string) string { + for _, c := range containers { + for _, env := range c.Env { + if strings.HasPrefix(env.Name, prefix) { + return env.Value + } + } + } + return "" +} + +// IsReady returns a condition that checks if the resource is ready. +func IsReady[T any](accessor StatusAccessor[T]) Condition[T] { + return func(obj T) bool { + return accessor(obj) + } +} + +// HasDifferentUID returns a condition that checks if the UID differs from original. +func HasDifferentUID[T any](accessor UIDAccessor[T], originalUID types.UID) Condition[T] { + return func(obj T) bool { + return accessor(obj) != originalUID + } +} + +// HasDifferentValue returns a condition that checks if a value differs from original. +func HasDifferentValue[T any, V comparable](accessor ValueAccessor[T, V], original V) Condition[T] { + return func(obj T) bool { + return accessor(obj) != original + } +} + +// And combines multiple conditions with AND logic. +func And[T any](conditions ...Condition[T]) Condition[T] { + return func(obj T) bool { + for _, cond := range conditions { + if !cond(obj) { + return false + } + } + return true + } +} + +// Or combines multiple conditions with OR logic. +func Or[T any](conditions ...Condition[T]) Condition[T] { + return func(obj T) bool { + for _, cond := range conditions { + if cond(obj) { + return true + } + } + return false + } +} + +// Always returns a condition that always returns true (for existence checks). +func Always[T any]() Condition[T] { + return func(obj T) bool { + return true + } +} + +// IsTriggeredJobForCronJob returns a condition that checks if a Job was triggered +// by Reloader for the specified CronJob (has owner reference and instantiate annotation). +func IsTriggeredJobForCronJob(cronJobName string) Condition[*batchv1.Job] { + return func(job *batchv1.Job) bool { + for _, ownerRef := range job.OwnerReferences { + if ownerRef.Kind == "CronJob" && ownerRef.Name == cronJobName { + if job.Annotations != nil { + if _, ok := job.Annotations["cronjob.kubernetes.io/instantiate"]; ok { + return true + } + } + } + } + return false + } +} + +// SPCPSVersionChanged returns a condition that checks if the SPCPS version has changed +// from the initial version and the SPCPS is mounted. +func SPCPSVersionChanged(initialVersion string) Condition[*csiv1.SecretProviderClassPodStatus] { + return func(spcps *csiv1.SecretProviderClassPodStatus) bool { + if !spcps.Status.Mounted || len(spcps.Status.Objects) == 0 { + return false + } + for _, obj := range spcps.Status.Objects { + if obj.Version != initialVersion { + return true + } + } + return false + } +} + +// SPCPSForSPC returns a condition that checks if the SPCPS references a specific +// SecretProviderClass and is mounted. +func SPCPSForSPC(spcName string) Condition[*csiv1.SecretProviderClassPodStatus] { + return func(spcps *csiv1.SecretProviderClassPodStatus) bool { + return spcps.Status.SecretProviderClassName == spcName && spcps.Status.Mounted + } +} + +// SPCPSForPod returns a condition that checks if the SPCPS references a specific +// pod and is mounted. +func SPCPSForPod(podName string) Condition[*csiv1.SecretProviderClassPodStatus] { + return func(spcps *csiv1.SecretProviderClassPodStatus) bool { + return spcps.Status.PodName == podName && spcps.Status.Mounted + } +} + +// SPCPSForPods returns a condition that checks if the SPCPS references any of the +// specified pods and is mounted. +func SPCPSForPods(podNames map[string]bool) Condition[*csiv1.SecretProviderClassPodStatus] { + return func(spcps *csiv1.SecretProviderClassPodStatus) bool { + return podNames[spcps.Status.PodName] && spcps.Status.Mounted + } +} diff --git a/test/e2e/utils/csi.go b/test/e2e/utils/csi.go new file mode 100644 index 00000000..3a34ff2a --- /dev/null +++ b/test/e2e/utils/csi.go @@ -0,0 +1,338 @@ +package utils + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/remotecommand" + csiv1 "sigs.k8s.io/secrets-store-csi-driver/apis/v1" + csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" +) + +// CSI Driver constants +const ( + // CSIDriverName is the name of the secrets-store CSI driver + CSIDriverName = "secrets-store.csi.k8s.io" + + // DefaultCSIProvider is the default provider name for testing (Vault) + DefaultCSIProvider = "vault" + + // VaultAddress is the default Vault address in the cluster + VaultAddress = "http://vault.vault:8200" + + // VaultRole is the Kubernetes auth role configured in Vault for testing + VaultRole = "test-role" + + // VaultNamespace is the namespace where Vault is deployed + VaultNamespace = "vault" + + // VaultPodName is the name of the Vault pod (dev mode) + VaultPodName = "vault-0" + + // CSIVolumeName is the default volume name for CSI volumes in tests + CSIVolumeName = "csi-secrets-store" + + // CSIMountPath is the default mount path for CSI volumes in tests + CSIMountPath = "/mnt/secrets-store" + + // CSIRotationPollInterval is how often CSI driver checks for secret changes + CSIRotationPollInterval = 2 * time.Second +) + +// NewCSIClient creates a new CSI client using the default kubeconfig. +func NewCSIClient() (csiclient.Interface, error) { + kubeconfig := GetKubeconfig() + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, fmt.Errorf("building config from kubeconfig: %w", err) + } + return NewCSIClientFromConfig(config) +} + +// NewCSIClientFromConfig creates a new CSI client from a rest.Config. +func NewCSIClientFromConfig(config *rest.Config) (csiclient.Interface, error) { + client, err := csiclient.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("creating CSI client: %w", err) + } + return client, nil +} + +// IsCSIDriverInstalled checks if the CSI secrets store driver CRDs are available in the cluster. +// This checks for the SecretProviderClass CRD which is required for CSI tests. +func IsCSIDriverInstalled(ctx context.Context, client csiclient.Interface) bool { + if client == nil { + return false + } + + // Try to list SecretProviderClasses - if CRD doesn't exist, this will fail + _, err := client.SecretsstoreV1().SecretProviderClasses("default").List(ctx, metav1.ListOptions{Limit: 1}) + return err == nil +} + +// IsVaultProviderInstalled checks if Vault CSI provider is installed by checking for the vault-csi-provider DaemonSet. +// This is used to determine if CSI tests with actual volume mounting can run. +func IsVaultProviderInstalled(ctx context.Context, kubeClient kubernetes.Interface) bool { + if kubeClient == nil { + return false + } + + // Check if vault-csi-provider DaemonSet exists in vault namespace + _, err := kubeClient.AppsV1().DaemonSets("vault").Get(ctx, "vault-csi-provider", metav1.GetOptions{}) + return err == nil +} + +// CreateSecretProviderClass creates a SecretProviderClass in the given namespace. +// If params is nil, it creates a Vault-compatible SecretProviderClass with default test settings. +func CreateSecretProviderClass(ctx context.Context, client csiclient.Interface, namespace, name string, params map[string]string) ( + *csiv1.SecretProviderClass, error, +) { + if params == nil { + params = map[string]string{ + "vaultAddress": VaultAddress, + "roleName": VaultRole, + "objects": `- objectName: "test-secret" + secretPath: "secret/data/test" + secretKey: "username"`, + } + } + + spc := &csiv1.SecretProviderClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: csiv1.SecretProviderClassSpec{ + Provider: DefaultCSIProvider, + Parameters: params, + }, + } + + created, err := client.SecretsstoreV1().SecretProviderClasses(namespace).Create(ctx, spc, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("creating SecretProviderClass %s/%s: %w", namespace, name, err) + } + return created, nil +} + +// CreateSecretProviderClassWithSecret creates a SecretProviderClass that fetches a specific secret from Vault. +// secretPath should be like "secret/mysecret" (the function converts it to KV v2 format "secret/data/mysecret"). +// secretKey is the key within that secret to fetch. +func CreateSecretProviderClassWithSecret(ctx context.Context, client csiclient.Interface, namespace, name, secretPath, secretKey string) ( + *csiv1.SecretProviderClass, error, +) { + kvV2Path := secretPath + if strings.HasPrefix(secretPath, "secret/") && !strings.HasPrefix(secretPath, "secret/data/") { + kvV2Path = strings.Replace(secretPath, "secret/", "secret/data/", 1) + } + + params := map[string]string{ + "vaultAddress": VaultAddress, + "roleName": VaultRole, + "objects": fmt.Sprintf( + `- objectName: "%s" + secretPath: "%s" + secretKey: "%s"`, secretKey, kvV2Path, secretKey, + ), + } + return CreateSecretProviderClass(ctx, client, namespace, name, params) +} + +// DeleteSecretProviderClass deletes a SecretProviderClass by name. +func DeleteSecretProviderClass(ctx context.Context, client csiclient.Interface, namespace, name string) error { + err := client.SecretsstoreV1().SecretProviderClasses(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleting SecretProviderClass %s/%s: %w", namespace, name, err) + } + return nil +} + +// UpdateSecretProviderClassPodStatusLabels updates only the labels on a SecretProviderClassPodStatus. +// This should NOT trigger a reload (used for negative testing to verify Reloader ignores label-only changes). +func UpdateSecretProviderClassPodStatusLabels(ctx context.Context, client csiclient.Interface, namespace, name string, labels map[string]string) error { + spcps, err := client.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("getting SecretProviderClassPodStatus %s/%s: %w", namespace, name, err) + } + + if spcps.Labels == nil { + spcps.Labels = make(map[string]string) + } + for k, v := range labels { + spcps.Labels[k] = v + } + + _, err = client.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Update(ctx, spcps, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("updating SecretProviderClassPodStatus labels %s/%s: %w", namespace, name, err) + } + return nil +} + +// ============================================================================= +// Vault Integration Helpers +// ============================================================================= + +// CreateVaultSecret creates a new secret in Vault. +// secretPath should be like "secret/test" (without "data" prefix - it's added automatically). +// data is a map of key-value pairs to store in the secret. +func CreateVaultSecret(ctx context.Context, kubeClient kubernetes.Interface, restConfig *rest.Config, secretPath string, data map[string]string) error { + return UpdateVaultSecret(ctx, kubeClient, restConfig, secretPath, data) +} + +// UpdateVaultSecret updates a secret in Vault. This triggers the CSI driver to +// sync the new secret version, which creates/updates the SecretProviderClassPodStatus. +// secretPath should be like "secret/test" (without "data" prefix - it's added automatically). +// data is a map of key-value pairs to store in the secret. +func UpdateVaultSecret(ctx context.Context, kubeClient kubernetes.Interface, restConfig *rest.Config, secretPath string, data map[string]string) error { + args := []string{"kv", "put", secretPath} + for k, v := range data { + args = append(args, fmt.Sprintf("%s=%s", k, v)) + } + + if err := execInVaultPod(ctx, kubeClient, restConfig, args); err != nil { + return fmt.Errorf("updating Vault secret %s: %w", secretPath, err) + } + return nil +} + +// DeleteVaultSecret deletes a secret from Vault. +// secretPath should be like "secret/test". +func DeleteVaultSecret(ctx context.Context, kubeClient kubernetes.Interface, restConfig *rest.Config, secretPath string) error { + args := []string{"kv", "metadata", "delete", secretPath} + if err := execInVaultPod(ctx, kubeClient, restConfig, args); err != nil { + if strings.Contains(err.Error(), "No value found") { + return nil + } + return fmt.Errorf("deleting Vault secret %s: %w", secretPath, err) + } + return nil +} + +// execInVaultPod executes a vault command in the Vault pod. +func execInVaultPod(ctx context.Context, kubeClient kubernetes.Interface, restConfig *rest.Config, args []string) error { + req := kubeClient.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(VaultPodName). + Namespace(VaultNamespace). + SubResource("exec"). + VersionedParams( + &corev1.PodExecOptions{ + Container: "vault", + Command: append([]string{"vault"}, args...), + Stdout: true, + Stderr: true, + }, scheme.ParameterCodec, + ) + + exec, err := remotecommand.NewSPDYExecutor(restConfig, "POST", req.URL()) + if err != nil { + return fmt.Errorf("creating executor: %w", err) + } + + var stdout, stderr bytes.Buffer + err = exec.StreamWithContext( + ctx, remotecommand.StreamOptions{ + Stdout: &stdout, + Stderr: &stderr, + }, + ) + if err != nil { + return fmt.Errorf("executing command: %w (stderr: %s)", err, stderr.String()) + } + + return nil +} + +// WaitForSPCPSVersionChange waits for the SecretProviderClassPodStatus version to change +// from the initial version using watches. This is used after updating a Vault secret to +// wait for CSI driver to sync the new version. +func WaitForSPCPSVersionChange(ctx context.Context, client csiclient.Interface, namespace, spcpsName, initialVersion string, timeout time.Duration) error { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return client.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Watch(ctx, opts) + } + + _, err := WatchUntil(ctx, watchFunc, spcpsName, SPCPSVersionChanged(initialVersion), timeout) + if errors.Is(err, ErrWatchTimeout) { + return fmt.Errorf("timeout waiting for SecretProviderClassPodStatus %s/%s version to change from %s", namespace, spcpsName, initialVersion) + } + return err +} + +// FindSPCPSForDeployment finds the SecretProviderClassPodStatus created by CSI driver +// for pods of a given deployment using watches. Returns the first matching SPCPS name. +func FindSPCPSForDeployment(ctx context.Context, csiClient csiclient.Interface, kubeClient kubernetes.Interface, namespace, deploymentName string, timeout time.Duration) ( + string, error, +) { + pods, err := kubeClient.CoreV1().Pods(namespace).List( + ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("app=%s", deploymentName), + }, + ) + if err != nil { + return "", fmt.Errorf("listing pods for deployment %s: %w", deploymentName, err) + } + + podNames := make(map[string]bool) + for _, pod := range pods.Items { + podNames[pod.Name] = true + } + + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return csiClient.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Watch(ctx, opts) + } + + spcps, err := WatchUntil(ctx, watchFunc, "", SPCPSForPods(podNames), timeout) + if errors.Is(err, ErrWatchTimeout) { + return "", fmt.Errorf("timeout finding SecretProviderClassPodStatus for deployment %s/%s", namespace, deploymentName) + } + if err != nil { + return "", err + } + return spcps.Name, nil +} + +// FindSPCPSForSPC finds the SecretProviderClassPodStatus created by CSI driver +// that references a specific SecretProviderClass using watches. Returns the first matching SPCPS name. +func FindSPCPSForSPC(ctx context.Context, csiClient csiclient.Interface, namespace, spcName string, timeout time.Duration) (string, error) { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return csiClient.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Watch(ctx, opts) + } + + spcps, err := WatchUntil(ctx, watchFunc, "", SPCPSForSPC(spcName), timeout) + if errors.Is(err, ErrWatchTimeout) { + return "", fmt.Errorf("timeout finding SecretProviderClassPodStatus for SPC %s/%s", namespace, spcName) + } + if err != nil { + return "", err + } + return spcps.Name, nil +} + +// GetSPCPSVersion gets the current version string from a SecretProviderClassPodStatus. +// Returns the version of the first object, or empty string if not found. +func GetSPCPSVersion(ctx context.Context, client csiclient.Interface, namespace, name string) (string, error) { + spcps, err := client.SecretsstoreV1().SecretProviderClassPodStatuses(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("getting SecretProviderClassPodStatus %s/%s: %w", namespace, name, err) + } + if len(spcps.Status.Objects) == 0 { + return "", nil + } + var versions []string + for _, obj := range spcps.Status.Objects { + versions = append(versions, obj.Version) + } + return strings.Join(versions, ","), nil +} diff --git a/test/e2e/utils/helm.go b/test/e2e/utils/helm.go new file mode 100644 index 00000000..320782d2 --- /dev/null +++ b/test/e2e/utils/helm.go @@ -0,0 +1,219 @@ +package utils + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Helm-related constants. +const ( + // DefaultTestImage is the default image to test if RELOADER_IMAGE is not set. + DefaultTestImage = "ghcr.io/stakater/reloader:test" + + // DefaultHelmReleaseName is the Helm release name for Reloader. + DefaultHelmReleaseName = "reloader" + + // DefaultHelmChartPath is the path to the Helm chart relative to project root. + DefaultHelmChartPath = "deployments/kubernetes/chart/reloader" + + // StakaterEnvVarPrefix is the prefix for Stakater environment variables. + StakaterEnvVarPrefix = "STAKATER_" +) + +// DeployOptions configures how Reloader is deployed. +type DeployOptions struct { + // Namespace to deploy Reloader into. + Namespace string + + // Image is the full image reference (e.g., "ghcr.io/stakater/reloader:test"). + Image string + + // Values are additional Helm values to set (key=value pairs). + Values map[string]string + + // ReleaseName is the Helm release name. Defaults to DefaultHelmReleaseName. + ReleaseName string + + // Timeout for Helm operations. Defaults to "120s". + Timeout string +} + +// DeployReloader deploys Reloader using Helm with the specified options. +func DeployReloader(opts DeployOptions) error { + projectDir, err := GetProjectDir() + if err != nil { + return fmt.Errorf("getting project dir: %w", err) + } + + if opts.ReleaseName == "" { + opts.ReleaseName = DefaultHelmReleaseName + } + if opts.Timeout == "" { + opts.Timeout = "180s" + } + if opts.Image == "" { + opts.Image = GetTestImage() + } + + cleanupClusterResources(opts.ReleaseName) + + chartPath := filepath.Join(projectDir, DefaultHelmChartPath) + + args := []string{ + "upgrade", "--install", opts.ReleaseName, + chartPath, + "--namespace", opts.Namespace, + "--create-namespace", + "--reset-values", + "--set", fmt.Sprintf("image.repository=%s", GetImageRepository(opts.Image)), + "--set", fmt.Sprintf("image.tag=%s", GetImageTag(opts.Image)), + "--set", "image.pullPolicy=IfNotPresent", + "--wait", + "--timeout", opts.Timeout, + } + + for key, value := range opts.Values { + args = append(args, "--set", fmt.Sprintf("%s=%s", key, value)) + } + + cmd := exec.Command("helm", args...) + output, err := Run(cmd) + if err != nil { + return fmt.Errorf("helm install failed: %s: %w", output, err) + } + + return nil +} + +// UndeployReloader removes the Reloader Helm release and cleans up cluster-scoped resources. +// This function waits for all resources to be fully deleted to prevent race conditions +// between test suites. +func UndeployReloader(namespace, releaseName string) error { + if releaseName == "" { + releaseName = DefaultHelmReleaseName + } + + cmd := exec.Command("helm", "uninstall", releaseName, "--namespace", namespace, "--ignore-not-found", "--wait") + output, err := Run(cmd) + if err != nil { + return fmt.Errorf("helm uninstall failed: %s: %w", output, err) + } + + clusterResources := []struct { + kind string + name string + }{ + {"clusterrole", releaseName + "-reloader-role"}, + {"clusterrolebinding", releaseName + "-reloader-role-binding"}, + } + + for _, res := range clusterResources { + cmd := exec.Command("kubectl", "delete", res.kind, res.name, "--ignore-not-found", "--wait=true") + _, _ = Run(cmd) + } + + waitForReloaderGone(namespace, releaseName) + + return nil +} + +// waitForReloaderGone waits for the Reloader deployment to be fully removed using kubectl wait. +// This is watch-based (kubectl wait --for=delete) rather than a polling loop. +func waitForReloaderGone(namespace, releaseName string) { + deploymentName := ReloaderDeploymentName(releaseName) + cmd := exec.Command("kubectl", "wait", + "deployment/"+deploymentName, + "--for=delete", + "--namespace", namespace, + "--timeout=120s", + ) + _, _ = Run(cmd) +} + +// cleanupClusterResources removes cluster-scoped resources that might be left over +// from a previous test run. This is called before deploying to ensure clean state. +func cleanupClusterResources(releaseName string) { + if releaseName == "" { + releaseName = DefaultHelmReleaseName + } + + clusterResources := []struct { + kind string + name string + }{ + {"clusterrole", releaseName + "-reloader-role"}, + {"clusterrolebinding", releaseName + "-reloader-role-binding"}, + } + + for _, res := range clusterResources { + cmd := exec.Command("kubectl", "delete", res.kind, res.name, "--ignore-not-found", "--wait=true") + _, _ = Run(cmd) + } +} + +// GetTestImage returns the test image from environment or the default. +func GetTestImage() string { + if img := os.Getenv("RELOADER_IMAGE"); img != "" { + return img + } + return DefaultTestImage +} + +// GetImageRepository extracts the repository (without tag or digest) from a full image reference. +// Examples: +// +// "ghcr.io/stakater/reloader:v1.0.0" -> "ghcr.io/stakater/reloader" +// "ghcr.io/stakater/reloader@sha256:abc123" -> "ghcr.io/stakater/reloader" +func GetImageRepository(image string) string { + // Digest-based: repo@sha256:hash — split at '@' + if idx := strings.Index(image, "@"); idx != -1 { + return image[:idx] + } + // Tag-based: repo:tag — split at last ':' only if it comes after the last '/' + if lastColon := strings.LastIndex(image, ":"); lastColon != -1 { + if lastSlash := strings.LastIndex(image, "/"); lastSlash < lastColon { + return image[:lastColon] + } + } + return image +} + +// GetImageTag extracts the tag from a full image reference. +// Examples: +// +// "ghcr.io/stakater/reloader:v1.0.0" -> "v1.0.0" +// "ghcr.io/stakater/reloader@sha256:abc123" -> "sha256:abc123" +// +// Returns "latest" if no tag or digest is found. +func GetImageTag(image string) string { + // Digest-based: return everything after '@' + if idx := strings.Index(image, "@"); idx != -1 { + return image[idx+1:] + } + // Tag-based: return everything after last ':' (only if it comes after the last '/') + if lastColon := strings.LastIndex(image, ":"); lastColon != -1 { + if lastSlash := strings.LastIndex(image, "/"); lastSlash < lastColon { + return image[lastColon+1:] + } + } + return "latest" +} + +// ReloaderDeploymentName returns the full deployment name for Reloader. +func ReloaderDeploymentName(releaseName string) string { + if releaseName == "" { + releaseName = DefaultHelmReleaseName + } + return releaseName + "-reloader" +} + +// ReloaderPodSelector returns the label selector for Reloader pods. +func ReloaderPodSelector(releaseName string) string { + if releaseName == "" { + releaseName = DefaultHelmReleaseName + } + return "app=" + releaseName + "-reloader" +} diff --git a/test/e2e/utils/helm_test.go b/test/e2e/utils/helm_test.go new file mode 100644 index 00000000..2e334ebe --- /dev/null +++ b/test/e2e/utils/helm_test.go @@ -0,0 +1,172 @@ +package utils + +import ( + "testing" +) + +func TestGetImageRepository(t *testing.T) { + tests := []struct { + name string + image string + expected string + }{ + { + name: "full image with tag", + image: "ghcr.io/stakater/reloader:v1.0.0", + expected: "ghcr.io/stakater/reloader", + }, + { + name: "image with latest tag", + image: "nginx:latest", + expected: "nginx", + }, + { + name: "image without tag", + image: "ghcr.io/stakater/reloader", + expected: "ghcr.io/stakater/reloader", + }, + { + name: "image with digest", + image: "nginx@sha256:abc123", + expected: "nginx", + }, + { + name: "full image with digest", + image: "ghcr.io/stakater/reloader@sha256:deadbeef", + expected: "ghcr.io/stakater/reloader", + }, + { + name: "simple image name", + image: "nginx", + expected: "nginx", + }, + { + name: "image with port in registry", + image: "localhost:5000/myimage:v1", + expected: "localhost:5000/myimage", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetImageRepository(tt.image) + if result != tt.expected { + t.Errorf("GetImageRepository(%q) = %q, want %q", tt.image, result, tt.expected) + } + }) + } +} + +func TestGetImageTag(t *testing.T) { + tests := []struct { + name string + image string + expected string + }{ + { + name: "full image with tag", + image: "ghcr.io/stakater/reloader:v1.0.0", + expected: "v1.0.0", + }, + { + name: "image with latest tag", + image: "nginx:latest", + expected: "latest", + }, + { + name: "image without tag", + image: "ghcr.io/stakater/reloader", + expected: "latest", + }, + { + name: "simple image name", + image: "nginx", + expected: "latest", + }, + { + name: "image with port in registry", + image: "localhost:5000/myimage:v1", + expected: "v1", + }, + { + name: "tag with sha", + image: "myimage:sha-abc123", + expected: "sha-abc123", + }, + { + name: "image with digest", + image: "nginx@sha256:abc123", + expected: "sha256:abc123", + }, + { + name: "full image with digest", + image: "ghcr.io/stakater/reloader@sha256:deadbeef", + expected: "sha256:deadbeef", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetImageTag(tt.image) + if result != tt.expected { + t.Errorf("GetImageTag(%q) = %q, want %q", tt.image, result, tt.expected) + } + }) + } +} + +func TestReloaderDeploymentName(t *testing.T) { + tests := []struct { + name string + releaseName string + expected string + }{ + { + name: "default release name", + releaseName: "", + expected: "reloader-reloader", + }, + { + name: "custom release name", + releaseName: "my-reloader", + expected: "my-reloader-reloader", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ReloaderDeploymentName(tt.releaseName) + if result != tt.expected { + t.Errorf("ReloaderDeploymentName(%q) = %q, want %q", tt.releaseName, result, tt.expected) + } + }) + } +} + +func TestReloaderPodSelector(t *testing.T) { + tests := []struct { + name string + releaseName string + expected string + }{ + { + name: "default release name", + releaseName: "", + expected: "app=reloader-reloader", + }, + { + name: "custom release name", + releaseName: "my-reloader", + expected: "app=my-reloader-reloader", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ReloaderPodSelector(tt.releaseName) + if result != tt.expected { + t.Errorf("ReloaderPodSelector(%q) = %q, want %q", tt.releaseName, result, tt.expected) + } + }) + } +} diff --git a/test/e2e/utils/openshift.go b/test/e2e/utils/openshift.go new file mode 100644 index 00000000..b2ec1d91 --- /dev/null +++ b/test/e2e/utils/openshift.go @@ -0,0 +1,23 @@ +package utils + +import ( + "k8s.io/client-go/discovery" +) + +// HasDeploymentConfigSupport checks if the cluster has OpenShift DeploymentConfig API available. +func HasDeploymentConfigSupport(discoveryClient discovery.DiscoveryInterface) bool { + _, apiLists, err := discoveryClient.ServerGroupsAndResources() + if err != nil { + return false + } + + for _, apiList := range apiLists { + for _, resource := range apiList.APIResources { + if resource.Kind == "DeploymentConfig" { + return true + } + } + } + + return false +} diff --git a/test/e2e/utils/podspec.go b/test/e2e/utils/podspec.go new file mode 100644 index 00000000..263bed9c --- /dev/null +++ b/test/e2e/utils/podspec.go @@ -0,0 +1,306 @@ +package utils + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/utils/ptr" +) + +// AddEnvFromSource adds ConfigMap or Secret envFrom to a container. +func AddEnvFromSource(spec *corev1.PodSpec, containerIdx int, name string, isSecret bool) { + if containerIdx >= len(spec.Containers) { + return + } + source := corev1.EnvFromSource{} + if isSecret { + source.SecretRef = &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + } + } else { + source.ConfigMapRef = &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + } + } + spec.Containers[containerIdx].EnvFrom = append(spec.Containers[containerIdx].EnvFrom, source) +} + +// AddVolume adds a volume and mount to a container. +func AddVolume(spec *corev1.PodSpec, containerIdx int, volume corev1.Volume, mountPath string) { + spec.Volumes = append(spec.Volumes, volume) + if containerIdx < len(spec.Containers) { + spec.Containers[containerIdx].VolumeMounts = append( + spec.Containers[containerIdx].VolumeMounts, + corev1.VolumeMount{Name: volume.Name, MountPath: mountPath}, + ) + } +} + +// AddConfigMapVolume adds ConfigMap volume and mount. +func AddConfigMapVolume(spec *corev1.PodSpec, containerIdx int, name string) { + AddVolume(spec, containerIdx, corev1.Volume{ + Name: "cm-" + name, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + }, "/etc/config/"+name) +} + +// AddSecretVolume adds Secret volume and mount. +func AddSecretVolume(spec *corev1.PodSpec, containerIdx int, name string) { + AddVolume(spec, containerIdx, corev1.Volume{ + Name: "secret-" + name, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: name}, + }, + }, "/etc/secrets/"+name) +} + +// AddProjectedVolume adds projected volume with ConfigMap and/or Secret. +func AddProjectedVolume(spec *corev1.PodSpec, containerIdx int, cmName, secretName string) { + sources := []corev1.VolumeProjection{} + if cmName != "" { + sources = append(sources, corev1.VolumeProjection{ + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: cmName}, + }, + }) + } + if secretName != "" { + sources = append(sources, corev1.VolumeProjection{ + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + }, + }) + } + AddVolume(spec, containerIdx, corev1.Volume{ + Name: "projected-config", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{Sources: sources}, + }, + }, "/etc/projected") +} + +// AddKeyRef adds env var from ConfigMap or Secret key. +func AddKeyRef(spec *corev1.PodSpec, containerIdx int, resourceName, key, envVarName string, isSecret bool) { + if containerIdx >= len(spec.Containers) { + return + } + envVar := corev1.EnvVar{Name: envVarName} + if isSecret { + envVar.ValueFrom = &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: resourceName}, + Key: key, + }, + } + } else { + envVar.ValueFrom = &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: resourceName}, + Key: key, + }, + } + } + spec.Containers[containerIdx].Env = append(spec.Containers[containerIdx].Env, envVar) +} + +// AddCSIVolume adds CSI volume referencing SecretProviderClass. +func AddCSIVolume(spec *corev1.PodSpec, containerIdx int, spcName string) { + volumeName := "csi-" + spcName + mountPath := "/mnt/secrets-store/" + spcName + spec.Volumes = append(spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + CSI: &corev1.CSIVolumeSource{ + Driver: CSIDriverName, + ReadOnly: ptr.To(true), + VolumeAttributes: map[string]string{ + "secretProviderClass": spcName, + }, + }, + }, + }) + if containerIdx < len(spec.Containers) { + spec.Containers[containerIdx].VolumeMounts = append( + spec.Containers[containerIdx].VolumeMounts, + corev1.VolumeMount{Name: volumeName, MountPath: mountPath, ReadOnly: true}, + ) + } +} + +// AddCSIInitContainer adds an init container that mounts a CSI SecretProviderClass volume. +// The init container is named "init-csi-{spcName}" to avoid collisions when multiple CSI +// volumes are mounted. The volume is only added if not already present (idempotent). +// This is distinct from AddCSIVolume which mounts into a regular container. +func AddCSIInitContainer(spec *corev1.PodSpec, spcName string) { + volumeName := "csi-" + spcName + mountPath := "/mnt/secrets-store/" + spcName + + hasVolume := false + for _, v := range spec.Volumes { + if v.Name == volumeName { + hasVolume = true + break + } + } + if !hasVolume { + spec.Volumes = append(spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + CSI: &corev1.CSIVolumeSource{ + Driver: CSIDriverName, + ReadOnly: ptr.To(true), + VolumeAttributes: map[string]string{ + "secretProviderClass": spcName, + }, + }, + }, + }) + } + spec.InitContainers = append(spec.InitContainers, corev1.Container{ + Name: "init-csi-" + spcName, + Image: DefaultImage, + Command: []string{"sh", "-c", "echo init done"}, + VolumeMounts: []corev1.VolumeMount{ + {Name: volumeName, MountPath: mountPath, ReadOnly: true}, + }, + }) +} + +// AddInitContainer adds init container with optional envFrom references. +func AddInitContainer(spec *corev1.PodSpec, cmName, secretName string) { + init := corev1.Container{ + Name: "init", + Image: DefaultImage, + Command: []string{"sh", "-c", "echo init done"}, + } + if cmName != "" { + init.EnvFrom = append(init.EnvFrom, corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: cmName}, + }, + }) + } + if secretName != "" { + init.EnvFrom = append(init.EnvFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + }, + }) + } + spec.InitContainers = append(spec.InitContainers, init) +} + +// AddInitContainerWithVolumes adds init container with volume mounts. +func AddInitContainerWithVolumes(spec *corev1.PodSpec, cmName, secretName string) { + init := corev1.Container{ + Name: "init", + Image: DefaultImage, + Command: []string{"sh", "-c", "echo init done"}, + } + if cmName != "" { + volumeName := "init-cm-" + cmName + spec.Volumes = append(spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: cmName}, + }, + }, + }) + init.VolumeMounts = append(init.VolumeMounts, corev1.VolumeMount{ + Name: volumeName, + MountPath: "/etc/init-config/" + cmName, + }) + } + if secretName != "" { + volumeName := "init-secret-" + secretName + spec.Volumes = append(spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{SecretName: secretName}, + }, + }) + init.VolumeMounts = append(init.VolumeMounts, corev1.VolumeMount{ + Name: volumeName, + MountPath: "/etc/init-secrets/" + secretName, + }) + } + spec.InitContainers = append(spec.InitContainers, init) +} + +// ApplyWorkloadConfig applies all WorkloadConfig settings to a PodTemplateSpec. +// This includes both pod template annotations and pod spec configuration. +func ApplyWorkloadConfig(template *corev1.PodTemplateSpec, cfg WorkloadConfig) { + if len(cfg.PodTemplateAnnotations) > 0 { + if template.Annotations == nil { + template.Annotations = make(map[string]string) + } + for k, v := range cfg.PodTemplateAnnotations { + template.Annotations[k] = v + } + } + + spec := &template.Spec + if cfg.UseConfigMapEnvFrom && cfg.ConfigMapName != "" { + AddEnvFromSource(spec, 0, cfg.ConfigMapName, false) + } + if cfg.UseSecretEnvFrom && cfg.SecretName != "" { + AddEnvFromSource(spec, 0, cfg.SecretName, true) + } + if cfg.UseConfigMapVolume && cfg.ConfigMapName != "" { + AddConfigMapVolume(spec, 0, cfg.ConfigMapName) + } + if cfg.UseSecretVolume && cfg.SecretName != "" { + AddSecretVolume(spec, 0, cfg.SecretName) + } + if cfg.UseProjectedVolume { + AddProjectedVolume(spec, 0, cfg.ConfigMapName, cfg.SecretName) + } + if cfg.UseConfigMapKeyRef && cfg.ConfigMapName != "" { + key := cfg.ConfigMapKey + if key == "" { + key = "key" + } + envVar := cfg.EnvVarName + if envVar == "" { + envVar = "CONFIG_VAR" + } + AddKeyRef(spec, 0, cfg.ConfigMapName, key, envVar, false) + } + if cfg.UseSecretKeyRef && cfg.SecretName != "" { + key := cfg.SecretKey + if key == "" { + key = "key" + } + envVar := cfg.EnvVarName + if envVar == "" { + envVar = "SECRET_VAR" + } + AddKeyRef(spec, 0, cfg.SecretName, key, envVar, true) + } + if cfg.UseCSIVolume && cfg.SPCName != "" { + AddCSIVolume(spec, 0, cfg.SPCName) + } + if cfg.UseInitContainer { + AddInitContainer(spec, cfg.ConfigMapName, cfg.SecretName) + } + if cfg.UseInitContainerVolume { + AddInitContainerWithVolumes(spec, cfg.ConfigMapName, cfg.SecretName) + } + if cfg.UseInitContainerCSI && cfg.SPCName != "" { + AddCSIInitContainer(spec, cfg.SPCName) + } + if cfg.MultipleContainers > 1 { + for i := 1; i < cfg.MultipleContainers; i++ { + spec.Containers = append(spec.Containers, corev1.Container{ + Name: fmt.Sprintf("container-%d", i), + Image: DefaultImage, + Command: []string{"sh", "-c", DefaultCommand}, + }) + } + } +} diff --git a/test/e2e/utils/rand.go b/test/e2e/utils/rand.go new file mode 100644 index 00000000..601b14ab --- /dev/null +++ b/test/e2e/utils/rand.go @@ -0,0 +1,26 @@ +package utils + +import ( + "math/rand" + "time" +) + +const letters = "abcdefghijklmnopqrstuvwxyz" + +var randSource = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec + +// RandSeq generates a random lowercase string of length n. +// This is useful for creating unique resource names in tests. +func RandSeq(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letters[randSource.Intn(len(letters))] + } + return string(b) +} + +// RandName generates a unique name with the given prefix. +// Format: prefix-xxxxx where x is a random lowercase letter. +func RandName(prefix string) string { + return prefix + "-" + RandSeq(5) +} diff --git a/test/e2e/utils/rand_test.go b/test/e2e/utils/rand_test.go new file mode 100644 index 00000000..6dea5539 --- /dev/null +++ b/test/e2e/utils/rand_test.go @@ -0,0 +1,122 @@ +package utils + +import ( + "regexp" + "testing" +) + +func TestRandSeq(t *testing.T) { + tests := []struct { + name string + length int + }{ + {"length 0", 0}, + {"length 1", 1}, + {"length 5", 5}, + {"length 10", 10}, + {"length 100", 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RandSeq(tt.length) + + if len(result) != tt.length { + t.Errorf("RandSeq(%d) returned string of length %d, want %d", + tt.length, len(result), tt.length) + } + + if tt.length > 0 { + matched, _ := regexp.MatchString("^[a-z]+$", result) + if !matched { + t.Errorf("RandSeq(%d) = %q, contains non-lowercase letters", tt.length, result) + } + } + }) + } +} + +func TestRandSeqRandomness(t *testing.T) { + const iterations = 10 + const length = 20 + + seen := make(map[string]bool) + for i := 0; i < iterations; i++ { + s := RandSeq(length) + if seen[s] { + t.Errorf("RandSeq generated duplicate: %q", s) + } + seen[s] = true + } + + if len(seen) != iterations { + t.Errorf("Expected %d unique strings, got %d", iterations, len(seen)) + } +} + +func TestRandName(t *testing.T) { + tests := []struct { + name string + prefix string + }{ + {"deploy prefix", "deploy"}, + {"cm prefix", "cm"}, + {"secret prefix", "secret"}, + {"test-app prefix", "test-app"}, + {"empty prefix", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := RandName(tt.prefix) + + expectedPrefix := tt.prefix + "-" + if len(result) <= len(expectedPrefix) { + t.Errorf("RandName(%q) = %q, too short", tt.prefix, result) + return + } + + if result[:len(expectedPrefix)] != expectedPrefix { + t.Errorf("RandName(%q) = %q, doesn't start with %q", + tt.prefix, result, expectedPrefix) + } + + suffix := result[len(expectedPrefix):] + if len(suffix) != 5 { + t.Errorf("RandName(%q) suffix length = %d, want 5", tt.prefix, len(suffix)) + } + + matched, _ := regexp.MatchString("^[a-z]{5}$", suffix) + if !matched { + t.Errorf("RandName(%q) suffix = %q, should be 5 lowercase letters", + tt.prefix, suffix) + } + }) + } +} + +func TestRandNameUniqueness(t *testing.T) { + const prefix = "test" + const iterations = 100 + + seen := make(map[string]bool) + for i := 0; i < iterations; i++ { + name := RandName(prefix) + if seen[name] { + t.Errorf("RandName generated duplicate: %q", name) + } + seen[name] = true + } +} + +func TestRandNameKubernetesCompatibility(t *testing.T) { + prefixes := []string{"deploy", "cm", "secret", "test-app", "my-resource"} + k8sNamePattern := regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) + + for _, prefix := range prefixes { + name := RandName(prefix) + if !k8sNamePattern.MatchString(name) { + t.Errorf("RandName(%q) = %q is not a valid Kubernetes name", prefix, name) + } + } +} diff --git a/test/e2e/utils/resources.go b/test/e2e/utils/resources.go new file mode 100644 index 00000000..7f0fa946 --- /dev/null +++ b/test/e2e/utils/resources.go @@ -0,0 +1,977 @@ +package utils + +import ( + "context" + "fmt" + "strings" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/utils/ptr" +) + +const ( + // DefaultImage is the default container image used for test workloads. + DefaultImage = "busybox:1.36" + // DefaultCommand is the default command for test containers. + DefaultCommand = "sleep 3600" +) + +// CreateNamespace creates a namespace with the given name. +func CreateNamespace(ctx context.Context, client kubernetes.Interface, name string) error { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + _, err := client.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + return err +} + +// CreateNamespaceWithLabels creates a namespace with the given name and labels. +func CreateNamespaceWithLabels(ctx context.Context, client kubernetes.Interface, name string, labels map[string]string) error { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + }, + } + _, err := client.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + return err +} + +// DeleteNamespace deletes the namespace with the given name. +func DeleteNamespace(ctx context.Context, client kubernetes.Interface, name string) error { + return client.CoreV1().Namespaces().Delete(ctx, name, metav1.DeleteOptions{}) +} + +// CreateConfigMap creates a ConfigMap with the given name, data, and optional annotations. +func CreateConfigMap(ctx context.Context, client kubernetes.Interface, namespace, name string, data map[string]string, annotations map[string]string) (*corev1.ConfigMap, error) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Data: data, + } + return client.CoreV1().ConfigMaps(namespace).Create(ctx, cm, metav1.CreateOptions{}) +} + +// CreateConfigMapWithLabels creates a ConfigMap with the given name, data, labels, and optional annotations. +func CreateConfigMapWithLabels(ctx context.Context, client kubernetes.Interface, namespace, name string, data map[string]string, labels, annotations map[string]string) (*corev1.ConfigMap, error) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + Annotations: annotations, + }, + Data: data, + } + return client.CoreV1().ConfigMaps(namespace).Create(ctx, cm, metav1.CreateOptions{}) +} + +// CreateSecret creates a Secret with the given name, data, and optional annotations. +func CreateSecret(ctx context.Context, client kubernetes.Interface, namespace, name string, data map[string][]byte, annotations map[string]string) (*corev1.Secret, error) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: annotations, + }, + Data: data, + } + return client.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}) +} + +// UpdateConfigMap updates a ConfigMap's data. +func UpdateConfigMap(ctx context.Context, client kubernetes.Interface, namespace, name string, data map[string]string) error { + cm, err := client.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + cm.Data = data + _, err = client.CoreV1().ConfigMaps(namespace).Update(ctx, cm, metav1.UpdateOptions{}) + return err +} + +// UpdateConfigMapLabels updates a ConfigMap's labels. +func UpdateConfigMapLabels(ctx context.Context, client kubernetes.Interface, namespace, name string, labels map[string]string) error { + cm, err := client.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + if cm.Labels == nil { + cm.Labels = make(map[string]string) + } + for k, v := range labels { + cm.Labels[k] = v + } + _, err = client.CoreV1().ConfigMaps(namespace).Update(ctx, cm, metav1.UpdateOptions{}) + return err +} + +// UpdateSecret updates a Secret's data. +func UpdateSecret(ctx context.Context, client kubernetes.Interface, namespace, name string, data map[string][]byte) error { + secret, err := client.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + secret.Data = data + _, err = client.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{}) + return err +} + +// UpdateSecretLabels updates a Secret's labels. +func UpdateSecretLabels(ctx context.Context, client kubernetes.Interface, namespace, name string, labels map[string]string) error { + secret, err := client.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + if secret.Labels == nil { + secret.Labels = make(map[string]string) + } + for k, v := range labels { + secret.Labels[k] = v + } + _, err = client.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{}) + return err +} + +// stringToByteMap converts a string map to a byte map for Secret data. +func stringToByteMap(data map[string]string) map[string][]byte { + result := make(map[string][]byte) + for k, v := range data { + result[k] = []byte(v) + } + return result +} + +// CreateSecretFromStrings creates a Secret with string data (convenience wrapper). +func CreateSecretFromStrings(ctx context.Context, client kubernetes.Interface, namespace, name string, data map[string]string, annotations map[string]string) (*corev1.Secret, error) { + return CreateSecret(ctx, client, namespace, name, stringToByteMap(data), annotations) +} + +// UpdateSecretFromStrings updates a Secret's data using string values. +func UpdateSecretFromStrings(ctx context.Context, client kubernetes.Interface, namespace, name string, data map[string]string) error { + return UpdateSecret(ctx, client, namespace, name, stringToByteMap(data)) +} + +// DeleteConfigMap deletes a ConfigMap. +func DeleteConfigMap(ctx context.Context, client kubernetes.Interface, namespace, name string) error { + return client.CoreV1().ConfigMaps(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// DeleteSecret deletes a Secret. +func DeleteSecret(ctx context.Context, client kubernetes.Interface, namespace, name string) error { + return client.CoreV1().Secrets(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// DeploymentOption is a functional option for configuring a Deployment. +type DeploymentOption func(*appsv1.Deployment) + +// CreateDeployment creates a Deployment with the given options. +func CreateDeployment(ctx context.Context, client kubernetes.Interface, namespace, name string, opts ...DeploymentOption) (*appsv1.Deployment, error) { + deploy := baseDeploymentResource(namespace, name) + for _, opt := range opts { + opt(deploy) + } + return client.AppsV1().Deployments(namespace).Create(ctx, deploy, metav1.CreateOptions{}) +} + +// WithAnnotations adds annotations to the Deployment metadata. +func WithAnnotations(annotations map[string]string) DeploymentOption { + return func(d *appsv1.Deployment) { + if d.Annotations == nil { + d.Annotations = make(map[string]string) + } + for k, v := range annotations { + d.Annotations[k] = v + } + } +} + +// WithConfigMapEnvFrom adds an envFrom reference to a ConfigMap. +func WithConfigMapEnvFrom(name string) DeploymentOption { + return func(d *appsv1.Deployment) { + d.Spec.Template.Spec.Containers[0].EnvFrom = append( + d.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + ) + } +} + +// WithSecretEnvFrom adds an envFrom reference to a Secret. +func WithSecretEnvFrom(name string) DeploymentOption { + return func(d *appsv1.Deployment) { + d.Spec.Template.Spec.Containers[0].EnvFrom = append( + d.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + ) + } +} + +// WithConfigMapVolume adds a volume mount for a ConfigMap. +func WithConfigMapVolume(name string) DeploymentOption { + return func(d *appsv1.Deployment) { + volumeName := fmt.Sprintf("cm-%s", name) + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + }) + d.Spec.Template.Spec.Containers[0].VolumeMounts = append( + d.Spec.Template.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{ + Name: volumeName, + MountPath: fmt.Sprintf("/etc/config/%s", name), + }, + ) + } +} + +// WithSecretVolume adds a volume mount for a Secret. +func WithSecretVolume(name string) DeploymentOption { + return func(d *appsv1.Deployment) { + volumeName := fmt.Sprintf("secret-%s", name) + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: name, + }, + }, + }) + d.Spec.Template.Spec.Containers[0].VolumeMounts = append( + d.Spec.Template.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{ + Name: volumeName, + MountPath: fmt.Sprintf("/etc/secrets/%s", name), + }, + ) + } +} + +// WithProjectedVolume adds a projected volume with ConfigMap and/or Secret sources. +func WithProjectedVolume(cmName, secretName string) DeploymentOption { + return func(d *appsv1.Deployment) { + volumeName := "projected-config" + sources := []corev1.VolumeProjection{} + + if cmName != "" { + sources = append(sources, corev1.VolumeProjection{ + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: cmName}, + }, + }) + } + if secretName != "" { + sources = append(sources, corev1.VolumeProjection{ + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + }, + }) + } + + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: sources, + }, + }, + }) + d.Spec.Template.Spec.Containers[0].VolumeMounts = append( + d.Spec.Template.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{ + Name: volumeName, + MountPath: "/etc/projected", + }, + ) + } +} + +// WithInitContainer adds an init container that references ConfigMap and/or Secret. +func WithInitContainer(cmName, secretName string) DeploymentOption { + return func(d *appsv1.Deployment) { + initContainer := corev1.Container{ + Name: "init", + Image: DefaultImage, + Command: []string{"sh", "-c", "echo init done"}, + } + + if cmName != "" { + initContainer.EnvFrom = append(initContainer.EnvFrom, corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: cmName}, + }, + }) + } + if secretName != "" { + initContainer.EnvFrom = append(initContainer.EnvFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + }, + }) + } + + d.Spec.Template.Spec.InitContainers = append(d.Spec.Template.Spec.InitContainers, initContainer) + } +} + +// WithMultipleContainers adds additional containers to the pod. +func WithMultipleContainers(count int) DeploymentOption { + return func(d *appsv1.Deployment) { + for i := 1; i < count; i++ { + d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, corev1.Container{ + Name: fmt.Sprintf("container-%d", i), + Image: DefaultImage, + Command: []string{"sh", "-c", DefaultCommand}, + }) + } + } +} + +// WithMultipleContainersAndEnv creates two containers, each with a different ConfigMap envFrom. +func WithMultipleContainersAndEnv(cm1Name, cm2Name string) DeploymentOption { + return func(d *appsv1.Deployment) { + d.Spec.Template.Spec.Containers[0].EnvFrom = append(d.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: cm1Name}, + }, + }) + d.Spec.Template.Spec.Containers = append(d.Spec.Template.Spec.Containers, corev1.Container{ + Name: "container-1", + Image: DefaultImage, + Command: []string{"sh", "-c", DefaultCommand}, + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: cm2Name}, + }, + }, + }, + }) + } +} + +// WithReplicas sets the number of replicas. +func WithReplicas(replicas int32) DeploymentOption { + return func(d *appsv1.Deployment) { + d.Spec.Replicas = ptr.To(replicas) + } +} + +// WithConfigMapKeyRef adds a valueFrom.configMapKeyRef env var to the container. +func WithConfigMapKeyRef(cmName, key, envVarName string) DeploymentOption { + return func(d *appsv1.Deployment) { + d.Spec.Template.Spec.Containers[0].Env = append( + d.Spec.Template.Spec.Containers[0].Env, + corev1.EnvVar{ + Name: envVarName, + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: cmName}, + Key: key, + }, + }, + }, + ) + } +} + +// WithSecretKeyRef adds a valueFrom.secretKeyRef env var to the container. +func WithSecretKeyRef(secretName, key, envVarName string) DeploymentOption { + return func(d *appsv1.Deployment) { + d.Spec.Template.Spec.Containers[0].Env = append( + d.Spec.Template.Spec.Containers[0].Env, + corev1.EnvVar{ + Name: envVarName, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + Key: key, + }, + }, + }, + ) + } +} + +// WithPodTemplateAnnotations adds annotations to the pod template metadata (not deployment metadata). +func WithPodTemplateAnnotations(annotations map[string]string) DeploymentOption { + return func(d *appsv1.Deployment) { + if d.Spec.Template.Annotations == nil { + d.Spec.Template.Annotations = make(map[string]string) + } + for k, v := range annotations { + d.Spec.Template.Annotations[k] = v + } + } +} + +// WithInitContainerVolume adds an init container with ConfigMap/Secret volume mounts. +func WithInitContainerVolume(cmName, secretName string) DeploymentOption { + return func(d *appsv1.Deployment) { + initContainer := corev1.Container{ + Name: "init", + Image: DefaultImage, + Command: []string{"sh", "-c", "echo init done"}, + } + + if cmName != "" { + volumeName := fmt.Sprintf("init-cm-%s", cmName) + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: cmName}, + }, + }, + }) + initContainer.VolumeMounts = append(initContainer.VolumeMounts, corev1.VolumeMount{ + Name: volumeName, + MountPath: fmt.Sprintf("/etc/init-config/%s", cmName), + }) + } + if secretName != "" { + volumeName := fmt.Sprintf("init-secret-%s", secretName) + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: secretName, + }, + }, + }) + initContainer.VolumeMounts = append(initContainer.VolumeMounts, corev1.VolumeMount{ + Name: volumeName, + MountPath: fmt.Sprintf("/etc/init-secrets/%s", secretName), + }) + } + + d.Spec.Template.Spec.InitContainers = append(d.Spec.Template.Spec.InitContainers, initContainer) + } +} + +// WithInitContainerProjectedVolume adds an init container with projected volume. +func WithInitContainerProjectedVolume(cmName, secretName string) DeploymentOption { + return func(d *appsv1.Deployment) { + volumeName := "init-projected-config" + sources := []corev1.VolumeProjection{} + + if cmName != "" { + sources = append(sources, corev1.VolumeProjection{ + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: cmName}, + }, + }) + } + if secretName != "" { + sources = append(sources, corev1.VolumeProjection{ + Secret: &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + }, + }) + } + + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: sources, + }, + }, + }) + + initContainer := corev1.Container{ + Name: "init", + Image: DefaultImage, + Command: []string{"sh", "-c", "echo init done"}, + VolumeMounts: []corev1.VolumeMount{ + { + Name: volumeName, + MountPath: "/etc/init-projected", + }, + }, + } + + d.Spec.Template.Spec.InitContainers = append(d.Spec.Template.Spec.InitContainers, initContainer) + } +} + +// WithCSIVolume adds a CSI volume referencing a SecretProviderClass to a Deployment. +func WithCSIVolume(spcName string) DeploymentOption { + return func(d *appsv1.Deployment) { + volumeName := csiVolumeName(spcName) + mountPath := csiMountPath(spcName) + + d.Spec.Template.Spec.Volumes = append(d.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + CSI: &corev1.CSIVolumeSource{ + Driver: CSIDriverName, + ReadOnly: ptr.To(true), + VolumeAttributes: map[string]string{ + "secretProviderClass": spcName, + }, + }, + }, + }) + d.Spec.Template.Spec.Containers[0].VolumeMounts = append( + d.Spec.Template.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{ + Name: volumeName, + MountPath: mountPath, + ReadOnly: true, + }, + ) + } +} + +// WithInitContainerCSIVolume adds an init container with a CSI volume mount. +func WithInitContainerCSIVolume(spcName string) DeploymentOption { + return func(d *appsv1.Deployment) { + AddCSIInitContainer(&d.Spec.Template.Spec, spcName) + } +} + +func baseDeploymentResource(namespace, name string) *appsv1.Deployment { + labels := map[string]string{"app": name} + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: DefaultImage, + Command: []string{"sh", "-c", DefaultCommand}, + }, + }, + }, + }, + }, + } +} + +// DeleteDeployment deletes a Deployment. +func DeleteDeployment(ctx context.Context, client kubernetes.Interface, namespace, name string) error { + return client.AppsV1().Deployments(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// DaemonSetOption is a functional option for configuring a DaemonSet. +type DaemonSetOption func(*appsv1.DaemonSet) + +// CreateDaemonSet creates a DaemonSet with the given options. +func CreateDaemonSet(ctx context.Context, client kubernetes.Interface, namespace, name string, opts ...DaemonSetOption) (*appsv1.DaemonSet, error) { + ds := baseDaemonSetResource(namespace, name) + for _, opt := range opts { + opt(ds) + } + return client.AppsV1().DaemonSets(namespace).Create(ctx, ds, metav1.CreateOptions{}) +} + +// baseDaemonSetResource creates a base DaemonSet template. +func baseDaemonSetResource(namespace, name string) *appsv1.DaemonSet { + labels := map[string]string{"app": name} + return &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: appsv1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: DefaultImage, + Command: []string{"sh", "-c", DefaultCommand}, + }, + }, + }, + }, + }, + } +} + +// DeleteDaemonSet deletes a DaemonSet. +func DeleteDaemonSet(ctx context.Context, client kubernetes.Interface, namespace, name string) error { + return client.AppsV1().DaemonSets(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// StatefulSetOption is a functional option for configuring a StatefulSet. +type StatefulSetOption func(*appsv1.StatefulSet) + +// CreateStatefulSet creates a StatefulSet with the given options. +func CreateStatefulSet(ctx context.Context, client kubernetes.Interface, namespace, name string, opts ...StatefulSetOption) (*appsv1.StatefulSet, error) { + ss := baseStatefulSetResource(namespace, name) + for _, opt := range opts { + opt(ss) + } + return client.AppsV1().StatefulSets(namespace).Create(ctx, ss, metav1.CreateOptions{}) +} + +// baseStatefulSetResource creates a base StatefulSet template. +func baseStatefulSetResource(namespace, name string) *appsv1.StatefulSet { + labels := map[string]string{"app": name} + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: appsv1.StatefulSetSpec{ + ServiceName: name, + Replicas: ptr.To(int32(1)), + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "app", + Image: DefaultImage, + Command: []string{"sh", "-c", DefaultCommand}, + }, + }, + }, + }, + }, + } +} + +// DeleteStatefulSet deletes a StatefulSet. +func DeleteStatefulSet(ctx context.Context, client kubernetes.Interface, namespace, name string) error { + return client.AppsV1().StatefulSets(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// CronJobOption is a functional option for configuring a CronJob. +type CronJobOption func(*batchv1.CronJob) + +// CreateCronJob creates a CronJob with the given options. +func CreateCronJob(ctx context.Context, client kubernetes.Interface, namespace, name string, opts ...CronJobOption) (*batchv1.CronJob, error) { + cj := baseCronJobResource(namespace, name) + for _, opt := range opts { + opt(cj) + } + return client.BatchV1().CronJobs(namespace).Create(ctx, cj, metav1.CreateOptions{}) +} + +// WithCronJobAnnotations adds annotations to the CronJob metadata. +func WithCronJobAnnotations(annotations map[string]string) CronJobOption { + return func(cj *batchv1.CronJob) { + if cj.Annotations == nil { + cj.Annotations = make(map[string]string) + } + for k, v := range annotations { + cj.Annotations[k] = v + } + } +} + +// WithCronJobConfigMapEnvFrom adds an envFrom reference to a ConfigMap. +func WithCronJobConfigMapEnvFrom(name string) CronJobOption { + return func(cj *batchv1.CronJob) { + cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].EnvFrom = append( + cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + ) + } +} + +// WithCronJobSecretEnvFrom adds an envFrom reference to a Secret. +func WithCronJobSecretEnvFrom(name string) CronJobOption { + return func(cj *batchv1.CronJob) { + cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].EnvFrom = append( + cj.Spec.JobTemplate.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + ) + } +} + +// baseCronJobResource creates a base CronJob template. +func baseCronJobResource(namespace, name string) *batchv1.CronJob { + labels := map[string]string{"app": name} + return &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "* * * * *", + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyOnFailure, + Containers: []corev1.Container{ + { + Name: "job", + Image: DefaultImage, + Command: []string{"sh", "-c", "echo done"}, + }, + }, + }, + }, + }, + }, + }, + } +} + +// DeleteCronJob deletes a CronJob. +func DeleteCronJob(ctx context.Context, client kubernetes.Interface, namespace, name string) error { + return client.BatchV1().CronJobs(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// JobOption is a functional option for configuring a Job. +type JobOption func(*batchv1.Job) + +// CreateJob creates a Job with the given options. +func CreateJob(ctx context.Context, client kubernetes.Interface, namespace, name string, opts ...JobOption) (*batchv1.Job, error) { + job := baseJobResource(namespace, name) + for _, opt := range opts { + opt(job) + } + return client.BatchV1().Jobs(namespace).Create(ctx, job, metav1.CreateOptions{}) +} + +// WithJobAnnotations adds annotations to the Job metadata. +func WithJobAnnotations(annotations map[string]string) JobOption { + return func(j *batchv1.Job) { + if j.Annotations == nil { + j.Annotations = make(map[string]string) + } + for k, v := range annotations { + j.Annotations[k] = v + } + } +} + +// WithJobConfigMapEnvFrom adds an envFrom reference to a ConfigMap. +func WithJobConfigMapEnvFrom(name string) JobOption { + return func(j *batchv1.Job) { + j.Spec.Template.Spec.Containers[0].EnvFrom = append( + j.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + ) + } +} + +// WithJobSecretEnvFrom adds an envFrom reference to a Secret. +func WithJobSecretEnvFrom(name string) JobOption { + return func(j *batchv1.Job) { + j.Spec.Template.Spec.Containers[0].EnvFrom = append( + j.Spec.Template.Spec.Containers[0].EnvFrom, + corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: name}, + }, + }, + ) + } +} + +// WithJobConfigMapKeyRef adds a valueFrom.configMapKeyRef env var to a Job. +func WithJobConfigMapKeyRef(cmName, key, envVarName string) JobOption { + return func(j *batchv1.Job) { + j.Spec.Template.Spec.Containers[0].Env = append( + j.Spec.Template.Spec.Containers[0].Env, + corev1.EnvVar{ + Name: envVarName, + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: cmName}, + Key: key, + }, + }, + }, + ) + } +} + +// WithJobSecretKeyRef adds a valueFrom.secretKeyRef env var to a Job. +func WithJobSecretKeyRef(secretName, key, envVarName string) JobOption { + return func(j *batchv1.Job) { + j.Spec.Template.Spec.Containers[0].Env = append( + j.Spec.Template.Spec.Containers[0].Env, + corev1.EnvVar{ + Name: envVarName, + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + Key: key, + }, + }, + }, + ) + } +} + +// WithJobCommand sets the command for the Job's container. +func WithJobCommand(command string) JobOption { + return func(j *batchv1.Job) { + j.Spec.Template.Spec.Containers[0].Command = []string{"sh", "-c", command} + } +} + +// WithJobCSIVolume adds a CSI volume referencing a SecretProviderClass to a Job. +func WithJobCSIVolume(spcName string) JobOption { + return func(j *batchv1.Job) { + volumeName := csiVolumeName(spcName) + mountPath := csiMountPath(spcName) + + j.Spec.Template.Spec.Volumes = append(j.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + CSI: &corev1.CSIVolumeSource{ + Driver: CSIDriverName, + ReadOnly: ptr.To(true), + VolumeAttributes: map[string]string{ + "secretProviderClass": spcName, + }, + }, + }, + }) + j.Spec.Template.Spec.Containers[0].VolumeMounts = append( + j.Spec.Template.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{ + Name: volumeName, + MountPath: mountPath, + ReadOnly: true, + }, + ) + } +} + +// baseJobResource creates a base Job template. +func baseJobResource(namespace, name string) *batchv1.Job { + labels := map[string]string{"app": name} + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "job", + Image: DefaultImage, + Command: []string{"sh", "-c", "echo done"}, + }, + }, + }, + }, + }, + } +} + +// DeleteJob deletes a Job. +func DeleteJob(ctx context.Context, client kubernetes.Interface, namespace, name string) error { + propagation := metav1.DeletePropagationBackground + return client.BatchV1().Jobs(namespace).Delete(ctx, name, metav1.DeleteOptions{ + PropagationPolicy: &propagation, + }) +} + +func csiVolumeName(spcName string) string { + return fmt.Sprintf("csi-%s", spcName) +} + +func csiMountPath(spcName string) string { + return fmt.Sprintf("/mnt/secrets-store/%s", spcName) +} + +// GetDeployment retrieves a deployment by name. +func GetDeployment(ctx context.Context, client kubernetes.Interface, namespace, name string) (*appsv1.Deployment, error) { + return client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) +} + +// GetPodLogs retrieves logs from pods matching the given label selector. +func GetPodLogs(ctx context.Context, client kubernetes.Interface, namespace, labelSelector string) (string, error) { + pods, err := client.CoreV1().Pods(namespace).List( + ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }, + ) + if err != nil { + return "", fmt.Errorf("failed to list pods: %w", err) + } + + var allLogs strings.Builder + for _, pod := range pods.Items { + for _, container := range pod.Spec.Containers { + logs, err := client.CoreV1().Pods(namespace).GetLogs( + pod.Name, &corev1.PodLogOptions{ + Container: container.Name, + }, + ).Do(ctx).Raw() + if err != nil { + allLogs.WriteString(fmt.Sprintf("Error getting logs for %s/%s: %v\n", pod.Name, container.Name, err)) + continue + } + allLogs.WriteString(fmt.Sprintf("=== %s/%s ===\n%s\n", pod.Name, container.Name, string(logs))) + } + } + + return allLogs.String(), nil +} diff --git a/test/e2e/utils/test_helpers.go b/test/e2e/utils/test_helpers.go new file mode 100644 index 00000000..f075b70e --- /dev/null +++ b/test/e2e/utils/test_helpers.go @@ -0,0 +1,12 @@ +package utils + +// MergeAnnotations merges multiple annotation maps into one. +func MergeAnnotations(maps ...map[string]string) map[string]string { + result := make(map[string]string) + for _, m := range maps { + for k, v := range m { + result[k] = v + } + } + return result +} diff --git a/test/e2e/utils/test_helpers_test.go b/test/e2e/utils/test_helpers_test.go new file mode 100644 index 00000000..0af5bcfb --- /dev/null +++ b/test/e2e/utils/test_helpers_test.go @@ -0,0 +1,143 @@ +package utils + +import ( + "testing" +) + +func TestMergeAnnotations(t *testing.T) { + tests := []struct { + name string + maps []map[string]string + expected map[string]string + }{ + { + name: "no maps", + maps: []map[string]string{}, + expected: map[string]string{}, + }, + { + name: "single map", + maps: []map[string]string{ + {"key1": "value1"}, + }, + expected: map[string]string{ + "key1": "value1", + }, + }, + { + name: "two maps no overlap", + maps: []map[string]string{ + {"key1": "value1"}, + {"key2": "value2"}, + }, + expected: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + { + name: "three maps with overlap - last wins", + maps: []map[string]string{ + {"key1": "value1", "shared": "first"}, + {"key2": "value2", "shared": "second"}, + {"key3": "value3", "shared": "third"}, + }, + expected: map[string]string{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + "shared": "third", + }, + }, + { + name: "empty map in the middle", + maps: []map[string]string{ + {"key1": "value1"}, + {}, + {"key2": "value2"}, + }, + expected: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + { + name: "nil map in the middle", + maps: []map[string]string{ + {"key1": "value1"}, + nil, + {"key2": "value2"}, + }, + expected: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + }, + { + name: "realistic use case - auto annotation with reload annotation", + maps: []map[string]string{ + BuildAutoTrueAnnotation(), + BuildConfigMapReloadAnnotation("my-config"), + }, + expected: map[string]string{ + AnnotationAuto: AnnotationValueTrue, + AnnotationConfigMapReload: "my-config", + }, + }, + { + name: "realistic use case - pause period with reload annotation", + maps: []map[string]string{ + BuildConfigMapReloadAnnotation("config1"), + BuildPausePeriodAnnotation("10s"), + }, + expected: map[string]string{ + AnnotationConfigMapReload: "config1", + AnnotationDeploymentPausePeriod: "10s", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MergeAnnotations(tt.maps...) + + if len(result) != len(tt.expected) { + t.Errorf("MergeAnnotations() returned %d entries, want %d", len(result), len(tt.expected)) + t.Errorf("Got: %v", result) + t.Errorf("Want: %v", tt.expected) + return + } + + for k, v := range tt.expected { + if result[k] != v { + t.Errorf("MergeAnnotations()[%q] = %q, want %q", k, result[k], v) + } + } + }) + } +} + +func TestMergeAnnotationsDoesNotModifyInput(t *testing.T) { + map1 := map[string]string{"key1": "value1"} + map2 := map[string]string{"key2": "value2"} + + _ = MergeAnnotations(map1, map2) + + if len(map1) != 1 || map1["key1"] != "value1" { + t.Errorf("map1 was modified: %v", map1) + } + if len(map2) != 1 || map2["key2"] != "value2" { + t.Errorf("map2 was modified: %v", map2) + } +} + +func TestMergeAnnotationsReturnsNewMap(t *testing.T) { + input := map[string]string{"key1": "value1"} + result := MergeAnnotations(input) + + result["key2"] = "value2" + + if _, exists := input["key2"]; exists { + t.Error("modifying result affected input map - should return a new map") + } +} diff --git a/test/e2e/utils/testenv.go b/test/e2e/utils/testenv.go new file mode 100644 index 00000000..a8be4551 --- /dev/null +++ b/test/e2e/utils/testenv.go @@ -0,0 +1,265 @@ +package utils + +import ( + "context" + "fmt" + "time" + + rolloutsclient "github.com/argoproj/argo-rollouts/pkg/client/clientset/versioned" + "github.com/onsi/ginkgo/v2" + openshiftclient "github.com/openshift/client-go/apps/clientset/versioned" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/discovery" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + csiclient "sigs.k8s.io/secrets-store-csi-driver/pkg/client/clientset/versioned" +) + +// TestEnvironment holds the common test environment state. +type TestEnvironment struct { + Ctx context.Context + Cancel context.CancelFunc + KubeClient kubernetes.Interface + DiscoveryClient discovery.DiscoveryInterface + CSIClient csiclient.Interface + RolloutsClient rolloutsclient.Interface + OpenShiftClient openshiftclient.Interface + RestConfig *rest.Config + Namespace string + ReleaseName string + TestImage string + ProjectDir string +} + +// SharedEnvData is passed from process 1 to all other processes via +// SynchronizedBeforeSuite. It carries the namespace and release name that +// process 1 created so the other processes can reuse them. +type SharedEnvData struct { + Namespace string `json:"namespace"` + ReleaseName string `json:"releaseName"` +} + +// SetupSharedTestEnvironment creates a TestEnvironment that connects to an +// already-provisioned namespace and Helm release. It builds Kubernetes clients +// but does NOT create a new namespace or deploy Reloader. Use this in the +// allProcsBody of SynchronizedBeforeSuite so that processes 2-N can share the +// single Reloader instance that process 1 deployed. +func SetupSharedTestEnvironment(ctx context.Context, namespace, releaseName string) (*TestEnvironment, error) { + childCtx, cancel := context.WithCancel(ctx) + env := &TestEnvironment{ + Ctx: childCtx, + Cancel: cancel, + TestImage: GetTestImage(), + Namespace: namespace, + ReleaseName: releaseName, + } + + var err error + + env.ProjectDir, err = GetProjectDir() + if err != nil { + cancel() + return nil, fmt.Errorf("getting project directory: %w", err) + } + + kubeconfig := GetKubeconfig() + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + cancel() + return nil, fmt.Errorf("building config from kubeconfig: %w", err) + } + env.RestConfig = config + + env.KubeClient, err = kubernetes.NewForConfig(config) + if err != nil { + cancel() + return nil, fmt.Errorf("creating kubernetes client: %w", err) + } + + env.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(config) + if err != nil { + cancel() + return nil, fmt.Errorf("creating discovery client: %w", err) + } + + // Optional clients — failures are non-fatal. + if env.CSIClient, err = csiclient.NewForConfig(config); err != nil { + env.CSIClient = nil + } + if env.RolloutsClient, err = rolloutsclient.NewForConfig(config); err != nil { + env.RolloutsClient = nil + } + if env.OpenShiftClient, err = openshiftclient.NewForConfig(config); err != nil { + env.OpenShiftClient = nil + } + + return env, nil +} + +// SetupTestEnvironment creates a new test environment with kubernetes clients. +// It creates a unique namespace with the given prefix. The returned env.Cancel must be +// called (e.g., in AfterSuite) to release the child context after env.Cleanup() completes. +func SetupTestEnvironment(ctx context.Context, namespacePrefix string) (*TestEnvironment, error) { + childCtx, cancel := context.WithCancel(ctx) + env := &TestEnvironment{ + Ctx: childCtx, + Cancel: cancel, + TestImage: GetTestImage(), + } + + var err error + + env.ProjectDir, err = GetProjectDir() + if err != nil { + return nil, fmt.Errorf("getting project directory: %w", err) + } + + kubeconfig := GetKubeconfig() + ginkgo.GinkgoWriter.Printf("Using kubeconfig: %s\n", kubeconfig) + + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, fmt.Errorf("building config from kubeconfig: %w", err) + } + + env.RestConfig = config + + env.KubeClient, err = kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("creating kubernetes client: %w", err) + } + + env.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(config) + if err != nil { + return nil, fmt.Errorf("creating discovery client: %w", err) + } + + env.CSIClient, err = csiclient.NewForConfig(config) + if err != nil { + ginkgo.GinkgoWriter.Printf("Warning: Could not create CSI client: %v (CSI tests will be skipped)\n", err) + env.CSIClient = nil + } + + // Try to create Argo Rollouts client (optional - may not be installed) + env.RolloutsClient, err = rolloutsclient.NewForConfig(config) + if err != nil { + ginkgo.GinkgoWriter.Printf("Warning: Could not create Rollouts client: %v (Argo tests will be skipped)\n", err) + env.RolloutsClient = nil + } + + // Try to create OpenShift client (optional - may not be installed) + env.OpenShiftClient, err = openshiftclient.NewForConfig(config) + if err != nil { + ginkgo.GinkgoWriter.Printf("Warning: Could not create OpenShift client: %v (OpenShift tests will be skipped)\n", + err) + env.OpenShiftClient = nil + } + + ginkgo.GinkgoWriter.Println("Verifying cluster connectivity...") + _, err = env.KubeClient.CoreV1().Namespaces().List(ctx, metav1.ListOptions{Limit: 1}) + if err != nil { + return nil, fmt.Errorf("connecting to kubernetes cluster: %w", err) + } + ginkgo.GinkgoWriter.Println("Cluster connectivity verified") + + env.Namespace = RandName(namespacePrefix) + env.ReleaseName = RandName("reloader") + ginkgo.GinkgoWriter.Printf("Creating test namespace: %s\n", env.Namespace) + ginkgo.GinkgoWriter.Printf("Using Helm release name: %s\n", env.ReleaseName) + if err := CreateNamespace(ctx, env.KubeClient, env.Namespace); err != nil { + return nil, fmt.Errorf("creating test namespace: %w", err) + } + + ginkgo.GinkgoWriter.Printf("Using test image: %s\n", env.TestImage) + ginkgo.GinkgoWriter.Printf("Project directory: %s\n", env.ProjectDir) + + return env, nil +} + +// CleanupOnFailure attempts a best-effort cleanup of the namespace used +// by this environment. It is intended to be deferred in a BeforeSuite +// so that orphaned namespaces don't accumulate on a long-lived cluster +// when the suite setup fails. Errors are logged but not fatal. +func (e *TestEnvironment) CleanupOnFailure() { + if e.Namespace == "" || e.KubeClient == nil { + return + } + cleanupCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + _ = UndeployReloader(e.Namespace, e.ReleaseName) + _ = DeleteNamespace(cleanupCtx, e.KubeClient, e.Namespace) +} + +// Cleanup cleans up the test environment resources. +// It uses a fresh context so it can run safely even after the suite context +// has been cancelled by SynchronizedAfterSuite. +func (e *TestEnvironment) Cleanup() error { + if e.Namespace == "" { + return nil + } + + // Use a fresh context with a generous timeout so cleanup works even + // after the per-process context (e.Ctx) has been cancelled. + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cleanupCancel() + + ginkgo.GinkgoWriter.Printf("Cleaning up test namespace: %s\n", e.Namespace) + ginkgo.GinkgoWriter.Printf("Cleaning up Helm release: %s\n", e.ReleaseName) + + logs, err := GetPodLogs(cleanupCtx, e.KubeClient, e.Namespace, ReloaderPodSelector(e.ReleaseName)) + if err == nil && logs != "" { + ginkgo.GinkgoWriter.Println("Reloader logs:") + ginkgo.GinkgoWriter.Println(logs) + } + + _ = UndeployReloader(e.Namespace, e.ReleaseName) + + if err := DeleteNamespace(cleanupCtx, e.KubeClient, e.Namespace); err != nil { + return fmt.Errorf("deleting namespace: %w", err) + } + + return nil +} + +// DeployReloaderWithStrategy deploys Reloader with the specified reload strategy. +func (e *TestEnvironment) DeployReloaderWithStrategy(strategy string) error { + return e.DeployReloaderWithValues( + map[string]string{ + "reloader.reloadStrategy": strategy, + }, + ) +} + +// DeployReloaderWithValues deploys Reloader with the specified Helm values. +// Each test suite uses a unique release name to prevent cluster-scoped resource conflicts. +func (e *TestEnvironment) DeployReloaderWithValues(values map[string]string) error { + ginkgo.GinkgoWriter.Printf("Deploying Reloader with values: %v\n", values) + return DeployReloader( + DeployOptions{ + Namespace: e.Namespace, + ReleaseName: e.ReleaseName, + Image: e.TestImage, + Values: values, + }, + ) +} + +// WaitForReloader waits for the Reloader deployment to be ready. +func (e *TestEnvironment) WaitForReloader() error { + ginkgo.GinkgoWriter.Println("Waiting for Reloader to be ready...") + adapter := NewDeploymentAdapter(e.KubeClient) + return adapter.WaitReady(e.Ctx, e.Namespace, ReloaderDeploymentName(e.ReleaseName), WorkloadReadyTimeout) +} + +// DeployAndWait deploys Reloader with the given values and waits for it to be ready. +func (e *TestEnvironment) DeployAndWait(values map[string]string) error { + if err := e.DeployReloaderWithValues(values); err != nil { + return fmt.Errorf("deploying Reloader: %w", err) + } + if err := e.WaitForReloader(); err != nil { + return fmt.Errorf("waiting for Reloader: %w", err) + } + ginkgo.GinkgoWriter.Println("Reloader is ready") + return nil +} diff --git a/test/e2e/utils/utils.go b/test/e2e/utils/utils.go new file mode 100644 index 00000000..85982a78 --- /dev/null +++ b/test/e2e/utils/utils.go @@ -0,0 +1,87 @@ +// Package utils provides helper functions for e2e tests. +package utils + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" //nolint:revive,staticcheck +) + +// Run executes the provided command and returns its combined stdout/stderr output. +// The command is executed from the project directory. +func Run(cmd *exec.Cmd) (string, error) { + dir, err := GetProjectDir() + if err != nil { + return "", fmt.Errorf("failed to get project dir: %w", err) + } + cmd.Dir = dir + + cmd.Env = append(os.Environ(), "GO111MODULE=on") + command := strings.Join(cmd.Args, " ") + _, _ = fmt.Fprintf(GinkgoWriter, "running: %q\n", command) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + output := stdout.String() + stderr.String() + if err != nil { + return output, fmt.Errorf("%q failed with error %q: %w", command, output, err) + } + + return output, nil +} + +// GetProjectDir returns the root directory of the project. +// It works by finding the directory containing go.mod. +func GetProjectDir() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current working directory: %w", err) + } + + // Walk up the directory tree looking for go.mod + dir := wd + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + // Reached root without finding go.mod + break + } + dir = parent + } + + // Fallback: try to strip common test paths + wd = strings.ReplaceAll(wd, "/test/e2e", "") + wd = strings.ReplaceAll(wd, "/test/e2e/annotations", "") + wd = strings.ReplaceAll(wd, "/test/e2e/envvars", "") + wd = strings.ReplaceAll(wd, "/test/e2e/flags", "") + wd = strings.ReplaceAll(wd, "/test/e2e/advanced", "") + wd = strings.ReplaceAll(wd, "/test/e2e/argo", "") + wd = strings.ReplaceAll(wd, "/test/e2e/openshift", "") + + return wd, nil +} + +// GetKubeconfig returns the path to the kubeconfig file. +// It checks KUBECONFIG environment variable first, then falls back to ~/.kube/config. +func GetKubeconfig() string { + if kubeconfig := os.Getenv("KUBECONFIG"); kubeconfig != "" { + return kubeconfig + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".kube", "config") +} diff --git a/test/e2e/utils/watch.go b/test/e2e/utils/watch.go new file mode 100644 index 00000000..d380bb82 --- /dev/null +++ b/test/e2e/utils/watch.go @@ -0,0 +1,231 @@ +package utils + +import ( + "context" + "errors" + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" //nolint:revive,staticcheck + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" +) + +// Timeout constants for watch operations. +const ( + DefaultInterval = 1 * time.Second // Polling interval + ShortTimeout = 5 * time.Second // Quick checks + NegativeTestWait = 3 * time.Second // Wait before checking negative conditions + WorkloadReadyTimeout = 60 * time.Second // Workload readiness timeout (buffer for CI) + ReloadTimeout = 15 * time.Second // Time for reload to trigger +) + +// ErrWatchTimeout is returned when a watch times out waiting for condition. +var ErrWatchTimeout = errors.New("watch timeout waiting for condition") + +// ErrWatchError is returned when the watch receives an error event from the API server. +var ErrWatchError = errors.New("watch received error event from API server") + +// ErrUnsupportedOperation is returned when an operation is not supported for a workload type. +var ErrUnsupportedOperation = errors.New("operation not supported for this workload type") + +// HandleWatchResult converts watch errors to the standard (bool, error) return pattern. +// Returns (false, nil) for timeout, (true, nil) for success, (false, err) for other errors. +func HandleWatchResult(err error) (bool, error) { + if errors.Is(err, ErrWatchTimeout) { + return false, nil + } + return err == nil, err +} + +// WatchFunc is a function that starts a watch for a specific resource. +type WatchFunc func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) + +// Condition is a function that checks if the desired state is reached. +type Condition[T any] func(T) bool + +// WatchUntil watches a resource until the condition is met or timeout occurs. +// It handles watch reconnection automatically on errors. +// If name is empty, it watches all resources and returns the first matching one. +// +// ResourceVersion "0" is used so the API server sends the current state as an +// initial ADDED event before streaming live updates, preventing the TOCTOU window +// where a reload that completes before WatchUntil is called would be missed. +func WatchUntil[T runtime.Object](ctx context.Context, watchFunc WatchFunc, name string, condition Condition[T], timeout time.Duration) (T, error) { + var zero T + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + opts := metav1.ListOptions{ + Watch: true, + ResourceVersion: "0", // receive current state as initial ADDED event + } + if name != "" { + opts.FieldSelector = fields.OneTermEqualSelector("metadata.name", name).String() + } + + const maxReconnectDelay = 5 * time.Second + reconnectDelay := 100 * time.Millisecond + + for { + select { + case <-ctx.Done(): + return zero, ErrWatchTimeout + default: + } + + result, done, err := watchOnce(ctx, watchFunc, opts, condition) + if done { + return result, err + } + select { + case <-ctx.Done(): + return zero, ErrWatchTimeout + case <-time.After(reconnectDelay): + if reconnectDelay < maxReconnectDelay { + reconnectDelay *= 2 + } + } + } +} + +// watchOnce starts a single watch and processes events until condition met or watch ends. +func watchOnce[T runtime.Object]( + ctx context.Context, + watchFunc WatchFunc, + opts metav1.ListOptions, + condition Condition[T], +) (T, bool, error) { + var zero T + + watcher, err := watchFunc(ctx, opts) + if err != nil { + // Log and signal retry; transient API errors are expected during CI. + _, _ = fmt.Fprintf(GinkgoWriter, "watch: failed to start watch: %v — retrying\n", err) + return zero, false, nil + } + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return zero, true, ErrWatchTimeout + case event, ok := <-watcher.ResultChan(): + if !ok { + return zero, false, nil + } + + switch event.Type { + case watch.Added, watch.Modified: + obj, ok := event.Object.(T) + if !ok { + continue + } + if condition(obj) { + return obj, true, nil + } + case watch.Deleted: + continue + case watch.Error: + _, _ = fmt.Fprintf(GinkgoWriter, "watch: received error event: %v — retrying\n", event.Object) + return zero, false, nil + } + } + } +} + +// WatchUntilDeleted watches until the resource is deleted or timeout occurs. +func WatchUntilDeleted( + ctx context.Context, + watchFunc WatchFunc, + name string, + timeout time.Duration, +) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + opts := metav1.ListOptions{ + FieldSelector: fields.OneTermEqualSelector("metadata.name", name).String(), + Watch: true, + ResourceVersion: "0", + } + + const maxReconnectDelay = 5 * time.Second + reconnectDelay := 100 * time.Millisecond + + for { + select { + case <-ctx.Done(): + return ErrWatchTimeout + default: + } + + deleted, err := watchDeleteOnce(ctx, watchFunc, opts) + if deleted { + return err + } + select { + case <-ctx.Done(): + return ErrWatchTimeout + case <-time.After(reconnectDelay): + if reconnectDelay < maxReconnectDelay { + reconnectDelay *= 2 + } + } + } +} + +func watchDeleteOnce( + ctx context.Context, + watchFunc WatchFunc, + opts metav1.ListOptions, +) (bool, error) { + watcher, err := watchFunc(ctx, opts) + if err != nil { + _, _ = fmt.Fprintf(GinkgoWriter, "watch: failed to start delete watch: %v — retrying\n", err) + return false, nil + } + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return true, ErrWatchTimeout + case event, ok := <-watcher.ResultChan(): + if !ok { + return false, nil + } + if event.Type == watch.Deleted { + return true, nil + } + if event.Type == watch.Error { + _, _ = fmt.Fprintf(GinkgoWriter, "watch: received error event during delete watch: %v — retrying\n", event.Object) + return false, nil + } + } + } +} + +// WatchUntilDifferentUID watches until the resource has a different UID (recreated). +func WatchUntilDifferentUID[T runtime.Object]( + ctx context.Context, + watchFunc WatchFunc, + name string, + originalUID string, + timeout time.Duration, + getUID func(T) string, +) (T, bool, error) { + var zero T + result, err := WatchUntil(ctx, watchFunc, name, func(obj T) bool { + return getUID(obj) != originalUID + }, timeout) + if errors.Is(err, ErrWatchTimeout) { + return zero, false, nil + } + if err != nil { + return zero, false, err + } + return result, true, nil +} diff --git a/test/e2e/utils/workload_adapter.go b/test/e2e/utils/workload_adapter.go new file mode 100644 index 00000000..bc7f80ce --- /dev/null +++ b/test/e2e/utils/workload_adapter.go @@ -0,0 +1,185 @@ +package utils + +import ( + "context" + "time" + + "k8s.io/client-go/kubernetes" +) + +// WorkloadType represents the type of Kubernetes workload. +type WorkloadType string + +const ( + WorkloadDeployment WorkloadType = "Deployment" + WorkloadDaemonSet WorkloadType = "DaemonSet" + WorkloadStatefulSet WorkloadType = "StatefulSet" + WorkloadCronJob WorkloadType = "CronJob" + WorkloadJob WorkloadType = "Job" + WorkloadArgoRollout WorkloadType = "ArgoRollout" + WorkloadDeploymentConfig WorkloadType = "DeploymentConfig" +) + +// ReloadStrategy represents the reload strategy used by Reloader. +type ReloadStrategy string + +const ( + StrategyAnnotations ReloadStrategy = "annotations" + StrategyEnvVars ReloadStrategy = "envvars" +) + +// WorkloadConfig holds configuration for workload creation. +type WorkloadConfig struct { + ConfigMapName string + SecretName string + SPCName string + Annotations map[string]string // Annotations for workload metadata (e.g., Deployment.metadata.annotations) + PodTemplateAnnotations map[string]string // Annotations for pod template metadata (e.g., Deployment.spec.template.metadata.annotations) + UseConfigMapEnvFrom bool + UseSecretEnvFrom bool + UseConfigMapVolume bool + UseSecretVolume bool + UseProjectedVolume bool + UseConfigMapKeyRef bool + UseSecretKeyRef bool + UseInitContainer bool + UseInitContainerVolume bool + UseCSIVolume bool + UseInitContainerCSI bool + ConfigMapKey string + SecretKey string + EnvVarName string + MultipleContainers int +} + +// WorkloadAdapter provides a unified interface for all workload types. +// This allows tests to be parameterized across different workload types. +type WorkloadAdapter interface { + // Type returns the workload type. + Type() WorkloadType + + // Create creates the workload with the given config. + Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error + + // Delete removes the workload. + Delete(ctx context.Context, namespace, name string) error + + // WaitReady waits for the workload to be ready. + WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error + + // WaitReloaded waits for the workload to have the reload annotation. + // Returns true if the annotation was found, false if timeout occurred. + WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) + + // WaitEnvVar waits for the workload to have a STAKATER_ env var (for envvars strategy). + // Returns true if the env var was found, false if timeout occurred. + WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) + + // SupportsEnvVarStrategy returns true if the workload supports env var reload strategy. + // CronJob does not support this as it uses job creation instead. + SupportsEnvVarStrategy() bool + + // RequiresSpecialHandling returns true for workloads that need special handling. + // For example, CronJob triggers a new job instead of rolling restart. + RequiresSpecialHandling() bool + + // GetPodTemplateAnnotation returns the value of a pod template annotation. + // This is useful for tests that need to compare annotation values before/after updates. + GetPodTemplateAnnotation(ctx context.Context, namespace, name, annotationKey string) (string, error) +} + +// Pausable is implemented by workloads that support pause/unpause. +// Currently only Deployment supports this capability. +type Pausable interface { + WaitPaused(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) + WaitUnpaused(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) +} + +// Recreatable is implemented by workloads that are recreated instead of updated. +// Currently only Job supports this capability (Jobs are immutable, so Reloader recreates them). +type Recreatable interface { + GetOriginalUID(ctx context.Context, namespace, name string) (string, error) + WaitRecreated(ctx context.Context, namespace, name, originalUID string, timeout time.Duration) (string, bool, error) +} + +// JobTriggerer is implemented by workloads that trigger jobs on reload. +// Currently only CronJob supports this capability. +type JobTriggerer interface { + WaitForTriggeredJob(ctx context.Context, namespace, name string, timeout time.Duration) (bool, error) +} + +// RestartAtSupporter is implemented by workloads that support the restartAt field. +// Currently only ArgoRollout supports this capability. +type RestartAtSupporter interface { + WaitRestartAt(ctx context.Context, namespace, name string, timeout time.Duration) (bool, error) +} + +// AdapterRegistry holds adapters for all workload types. +type AdapterRegistry struct { + kubeClient kubernetes.Interface + adapters map[WorkloadType]WorkloadAdapter +} + +// NewAdapterRegistry creates a new adapter registry with all standard adapters. +func NewAdapterRegistry(kubeClient kubernetes.Interface) *AdapterRegistry { + r := &AdapterRegistry{ + kubeClient: kubeClient, + adapters: make(map[WorkloadType]WorkloadAdapter), + } + + r.adapters[WorkloadDeployment] = NewDeploymentAdapter(kubeClient) + r.adapters[WorkloadDaemonSet] = NewDaemonSetAdapter(kubeClient) + r.adapters[WorkloadStatefulSet] = NewStatefulSetAdapter(kubeClient) + r.adapters[WorkloadCronJob] = NewCronJobAdapter(kubeClient) + r.adapters[WorkloadJob] = NewJobAdapter(kubeClient) + + return r +} + +// RegisterAdapter registers a custom adapter for a workload type. +func (r *AdapterRegistry) RegisterAdapter(adapter WorkloadAdapter) { + r.adapters[adapter.Type()] = adapter +} + +// Get returns the adapter for the given workload type. +// Returns nil if the adapter is not registered. +func (r *AdapterRegistry) Get(wt WorkloadType) WorkloadAdapter { + return r.adapters[wt] +} + +// GetStandardWorkloads returns the standard workload types that are always available. +func (r *AdapterRegistry) GetStandardWorkloads() []WorkloadType { + return []WorkloadType{ + WorkloadDeployment, + WorkloadDaemonSet, + WorkloadStatefulSet, + } +} + +// GetAllWorkloads returns all registered workload types in a canonical, deterministic order. +// Map iteration order in Go is non-deterministic, so this uses a fixed ordering to ensure +// consistent test parameterization across runs. +func (r *AdapterRegistry) GetAllWorkloads() []WorkloadType { + canonical := []WorkloadType{ + WorkloadDeployment, WorkloadDaemonSet, WorkloadStatefulSet, + WorkloadCronJob, WorkloadJob, WorkloadArgoRollout, WorkloadDeploymentConfig, + } + result := make([]WorkloadType, 0, len(r.adapters)) + for _, wt := range canonical { + if _, ok := r.adapters[wt]; ok { + result = append(result, wt) + } + } + return result +} + +// GetEnvVarWorkloads returns workload types that support env var reload strategy. +func (r *AdapterRegistry) GetEnvVarWorkloads() []WorkloadType { + result := make([]WorkloadType, 0) + for wt, adapter := range r.adapters { + if adapter.SupportsEnvVarStrategy() { + result = append(result, wt) + } + } + return result +} diff --git a/test/e2e/utils/workload_argo.go b/test/e2e/utils/workload_argo.go new file mode 100644 index 00000000..69d5163e --- /dev/null +++ b/test/e2e/utils/workload_argo.go @@ -0,0 +1,158 @@ +package utils + +import ( + "context" + "time" + + rolloutv1alpha1 "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" + rolloutsclient "github.com/argoproj/argo-rollouts/pkg/client/clientset/versioned" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/utils/ptr" +) + +// ArgoRolloutAdapter implements WorkloadAdapter for Argo Rollouts. +type ArgoRolloutAdapter struct { + rolloutsClient rolloutsclient.Interface +} + +// NewArgoRolloutAdapter creates a new ArgoRolloutAdapter. +func NewArgoRolloutAdapter(rolloutsClient rolloutsclient.Interface) *ArgoRolloutAdapter { + return &ArgoRolloutAdapter{ + rolloutsClient: rolloutsClient, + } +} + +// Type returns the workload type. +func (a *ArgoRolloutAdapter) Type() WorkloadType { + return WorkloadArgoRollout +} + +// Create creates an Argo Rollout with the given config. +func (a *ArgoRolloutAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error { + rollout := baseRollout(name) + opts := buildRolloutOptions(cfg) + for _, opt := range opts { + opt(rollout) + } + _, err := a.rolloutsClient.ArgoprojV1alpha1().Rollouts(namespace).Create(ctx, rollout, metav1.CreateOptions{}) + return err +} + +// Delete removes the Argo Rollout. +func (a *ArgoRolloutAdapter) Delete(ctx context.Context, namespace, name string) error { + return a.rolloutsClient.ArgoprojV1alpha1().Rollouts(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// WaitReady waits for the Argo Rollout to be ready using watches. +func (a *ArgoRolloutAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.rolloutsClient.ArgoprojV1alpha1().Rollouts(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, IsReady(RolloutIsReady), timeout) + return err +} + +// WaitReloaded waits for the Argo Rollout to have the reload annotation using watches. +// Captures the current annotation value first to avoid false positives from prior reloads. +func (a *ArgoRolloutAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) { + priorValue, _ := a.GetPodTemplateAnnotation(ctx, namespace, name, annotationKey) + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.rolloutsClient.ArgoprojV1alpha1().Rollouts(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasPodTemplateAnnotationChanged(RolloutPodTemplate, annotationKey, priorValue), timeout) + return HandleWatchResult(err) +} + +// WaitEnvVar waits for the Argo Rollout to have a STAKATER_ env var using watches. +// Captures the current env var value first to avoid false positives from prior reloads. +func (a *ArgoRolloutAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) { + priorValue := "" + if r, err := a.rolloutsClient.ArgoprojV1alpha1().Rollouts(namespace).Get(ctx, name, metav1.GetOptions{}); err == nil { + priorValue = GetEnvVarValueByPrefix(r.Spec.Template.Spec.Containers, prefix) + } + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.rolloutsClient.ArgoprojV1alpha1().Rollouts(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasEnvVarPrefixChanged(RolloutContainers, prefix, priorValue), timeout) + return HandleWatchResult(err) +} + +// WaitRestartAt waits for the Argo Rollout to have the restartAt field set using watches. +// This is used when Reloader is configured with rollout strategy=restart. +func (a *ArgoRolloutAdapter) WaitRestartAt(ctx context.Context, namespace, name string, timeout time.Duration) (bool, error) { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.rolloutsClient.ArgoprojV1alpha1().Rollouts(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, IsReady(RolloutHasRestartAt), timeout) + return HandleWatchResult(err) +} + +// SupportsEnvVarStrategy returns true as Argo Rollouts support env var reload strategy. +func (a *ArgoRolloutAdapter) SupportsEnvVarStrategy() bool { + return true +} + +// RequiresSpecialHandling returns false as Argo Rollouts use standard rolling restart. +func (a *ArgoRolloutAdapter) RequiresSpecialHandling() bool { + return false +} + +// GetPodTemplateAnnotation returns the value of a pod template annotation. +func (a *ArgoRolloutAdapter) GetPodTemplateAnnotation(ctx context.Context, namespace, name, annotationKey string) (string, error) { + rollout, err := a.rolloutsClient.ArgoprojV1alpha1().Rollouts(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", err + } + return rollout.Spec.Template.Annotations[annotationKey], nil +} + +// baseRollout returns a minimal Rollout template. +func baseRollout(name string) *rolloutv1alpha1.Rollout { + return &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: rolloutv1alpha1.RolloutSpec{ + Replicas: ptr.To[int32](1), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "main", + Image: DefaultImage, + Command: []string{"sh", "-c", DefaultCommand}, + }}, + }, + }, + Strategy: rolloutv1alpha1.RolloutStrategy{ + Canary: &rolloutv1alpha1.CanaryStrategy{ + Steps: []rolloutv1alpha1.CanaryStep{ + {SetWeight: ptr.To[int32](100)}, + }, + }, + }, + }, + } +} + +// buildRolloutOptions converts WorkloadConfig to RolloutOption slice. +func buildRolloutOptions(cfg WorkloadConfig) []RolloutOption { + return []RolloutOption{ + func(r *rolloutv1alpha1.Rollout) { + if len(cfg.Annotations) > 0 { + if r.Annotations == nil { + r.Annotations = make(map[string]string) + } + for k, v := range cfg.Annotations { + r.Annotations[k] = v + } + } + ApplyWorkloadConfig(&r.Spec.Template, cfg) + }, + } +} diff --git a/test/e2e/utils/workload_cronjob.go b/test/e2e/utils/workload_cronjob.go new file mode 100644 index 00000000..c681cce9 --- /dev/null +++ b/test/e2e/utils/workload_cronjob.go @@ -0,0 +1,108 @@ +package utils + +import ( + "context" + "time" + + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" +) + +// CronJobAdapter implements WorkloadAdapter for Kubernetes CronJobs. +type CronJobAdapter struct { + client kubernetes.Interface +} + +// NewCronJobAdapter creates a new CronJobAdapter. +func NewCronJobAdapter(client kubernetes.Interface) *CronJobAdapter { + return &CronJobAdapter{client: client} +} + +// Type returns the workload type. +func (a *CronJobAdapter) Type() WorkloadType { + return WorkloadCronJob +} + +// Create creates a CronJob with the given config. +func (a *CronJobAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error { + opts := buildCronJobOptions(cfg) + _, err := CreateCronJob(ctx, a.client, namespace, name, opts...) + return err +} + +// Delete removes the CronJob. +func (a *CronJobAdapter) Delete(ctx context.Context, namespace, name string) error { + return DeleteCronJob(ctx, a.client, namespace, name) +} + +// WaitReady waits for the CronJob to exist using watches. +func (a *CronJobAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.BatchV1().CronJobs(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, Always[*batchv1.CronJob](), timeout) + return err +} + +// WaitReloaded waits for the CronJob pod template to have the reload annotation using watches. +// Captures the current annotation value first to avoid false positives from prior reloads. +func (a *CronJobAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) { + priorValue, _ := a.GetPodTemplateAnnotation(ctx, namespace, name, annotationKey) + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.BatchV1().CronJobs(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasPodTemplateAnnotationChanged(CronJobPodTemplate, annotationKey, priorValue), timeout) + return HandleWatchResult(err) +} + +// WaitEnvVar returns an error because CronJobs don't support env var reload strategy. +func (a *CronJobAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) { + return false, ErrUnsupportedOperation +} + +// SupportsEnvVarStrategy returns false as CronJobs don't support env var reload strategy. +func (a *CronJobAdapter) SupportsEnvVarStrategy() bool { + return false +} + +// RequiresSpecialHandling returns true as CronJobs use job triggering instead of rolling restart. +func (a *CronJobAdapter) RequiresSpecialHandling() bool { + return true +} + +// WaitForTriggeredJob waits for Reloader to trigger a new Job from this CronJob using watches. +func (a *CronJobAdapter) WaitForTriggeredJob(ctx context.Context, namespace, cronJobName string, timeout time.Duration) (bool, error) { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.BatchV1().Jobs(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, "", IsTriggeredJobForCronJob(cronJobName), timeout) + return HandleWatchResult(err) +} + +// GetPodTemplateAnnotation returns the value of a pod template annotation. +func (a *CronJobAdapter) GetPodTemplateAnnotation(ctx context.Context, namespace, name, annotationKey string) (string, error) { + cj, err := a.client.BatchV1().CronJobs(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", err + } + return cj.Spec.JobTemplate.Spec.Template.Annotations[annotationKey], nil +} + +// buildCronJobOptions converts WorkloadConfig to CronJobOption slice. +func buildCronJobOptions(cfg WorkloadConfig) []CronJobOption { + return []CronJobOption{ + func(cj *batchv1.CronJob) { + if len(cfg.Annotations) > 0 { + if cj.Annotations == nil { + cj.Annotations = make(map[string]string) + } + for k, v := range cfg.Annotations { + cj.Annotations[k] = v + } + } + ApplyWorkloadConfig(&cj.Spec.JobTemplate.Spec.Template, cfg) + }, + } +} diff --git a/test/e2e/utils/workload_daemonset.go b/test/e2e/utils/workload_daemonset.go new file mode 100644 index 00000000..4a7a2b14 --- /dev/null +++ b/test/e2e/utils/workload_daemonset.go @@ -0,0 +1,108 @@ +package utils + +import ( + "context" + "time" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" +) + +// DaemonSetAdapter implements WorkloadAdapter for Kubernetes DaemonSets. +type DaemonSetAdapter struct { + client kubernetes.Interface +} + +// NewDaemonSetAdapter creates a new DaemonSetAdapter. +func NewDaemonSetAdapter(client kubernetes.Interface) *DaemonSetAdapter { + return &DaemonSetAdapter{client: client} +} + +// Type returns the workload type. +func (a *DaemonSetAdapter) Type() WorkloadType { + return WorkloadDaemonSet +} + +// Create creates a DaemonSet with the given config. +func (a *DaemonSetAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error { + opts := buildDaemonSetOptions(cfg) + _, err := CreateDaemonSet(ctx, a.client, namespace, name, opts...) + return err +} + +// Delete removes the DaemonSet. +func (a *DaemonSetAdapter) Delete(ctx context.Context, namespace, name string) error { + return DeleteDaemonSet(ctx, a.client, namespace, name) +} + +// WaitReady waits for the DaemonSet to be ready using watches. +func (a *DaemonSetAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.AppsV1().DaemonSets(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, IsReady(DaemonSetIsReady), timeout) + return err +} + +// WaitReloaded waits for the DaemonSet to have the reload annotation using watches. +// Captures the current annotation value first to avoid false positives from prior reloads. +func (a *DaemonSetAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) { + priorValue, _ := a.GetPodTemplateAnnotation(ctx, namespace, name, annotationKey) + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.AppsV1().DaemonSets(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasPodTemplateAnnotationChanged(DaemonSetPodTemplate, annotationKey, priorValue), timeout) + return HandleWatchResult(err) +} + +// WaitEnvVar waits for the DaemonSet to have a STAKATER_ env var using watches. +// Captures the current env var value first to avoid false positives from prior reloads. +func (a *DaemonSetAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) { + priorValue := "" + if ds, err := a.client.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{}); err == nil { + priorValue = GetEnvVarValueByPrefix(ds.Spec.Template.Spec.Containers, prefix) + } + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.AppsV1().DaemonSets(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasEnvVarPrefixChanged(DaemonSetContainers, prefix, priorValue), timeout) + return HandleWatchResult(err) +} + +// SupportsEnvVarStrategy returns true as DaemonSets support env var reload strategy. +func (a *DaemonSetAdapter) SupportsEnvVarStrategy() bool { + return true +} + +// RequiresSpecialHandling returns false as DaemonSets use standard rolling restart. +func (a *DaemonSetAdapter) RequiresSpecialHandling() bool { + return false +} + +// GetPodTemplateAnnotation returns the value of a pod template annotation. +func (a *DaemonSetAdapter) GetPodTemplateAnnotation(ctx context.Context, namespace, name, annotationKey string) (string, error) { + ds, err := a.client.AppsV1().DaemonSets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", err + } + return ds.Spec.Template.Annotations[annotationKey], nil +} + +// buildDaemonSetOptions converts WorkloadConfig to DaemonSetOption slice. +func buildDaemonSetOptions(cfg WorkloadConfig) []DaemonSetOption { + return []DaemonSetOption{ + func(ds *appsv1.DaemonSet) { + if len(cfg.Annotations) > 0 { + if ds.Annotations == nil { + ds.Annotations = make(map[string]string) + } + for k, v := range cfg.Annotations { + ds.Annotations[k] = v + } + } + ApplyWorkloadConfig(&ds.Spec.Template, cfg) + }, + } +} diff --git a/test/e2e/utils/workload_deployment.go b/test/e2e/utils/workload_deployment.go new file mode 100644 index 00000000..f7ef5e37 --- /dev/null +++ b/test/e2e/utils/workload_deployment.go @@ -0,0 +1,128 @@ +package utils + +import ( + "context" + "time" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" +) + +// DeploymentAdapter implements WorkloadAdapter for Kubernetes Deployments. +type DeploymentAdapter struct { + client kubernetes.Interface +} + +// NewDeploymentAdapter creates a new DeploymentAdapter. +func NewDeploymentAdapter(client kubernetes.Interface) *DeploymentAdapter { + return &DeploymentAdapter{client: client} +} + +// Type returns the workload type. +func (a *DeploymentAdapter) Type() WorkloadType { + return WorkloadDeployment +} + +// Create creates a Deployment with the given config. +func (a *DeploymentAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error { + opts := buildDeploymentOptions(cfg) + _, err := CreateDeployment(ctx, a.client, namespace, name, opts...) + return err +} + +// Delete removes the Deployment. +func (a *DeploymentAdapter) Delete(ctx context.Context, namespace, name string) error { + return DeleteDeployment(ctx, a.client, namespace, name) +} + +// WaitReady waits for the Deployment to be ready using watches. +func (a *DeploymentAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.AppsV1().Deployments(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, IsReady(DeploymentIsReady), timeout) + return err +} + +// WaitReloaded waits for the Deployment to have the reload annotation using watches. +// It captures the current annotation value before watching so that a prior reload's annotation +// does not cause a false positive — the condition triggers only when the value changes. +func (a *DeploymentAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) { + priorValue, _ := a.GetPodTemplateAnnotation(ctx, namespace, name, annotationKey) + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.AppsV1().Deployments(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasPodTemplateAnnotationChanged(DeploymentPodTemplate, annotationKey, priorValue), timeout) + return HandleWatchResult(err) +} + +// WaitEnvVar waits for the Deployment to have a STAKATER_ env var using watches. +// It captures the current env var value before watching so that a prior reload's value does not +// cause a false positive — the condition triggers only when the value appears or changes. +func (a *DeploymentAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) { + priorValue := "" + if d, err := a.client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}); err == nil { + priorValue = GetEnvVarValueByPrefix(d.Spec.Template.Spec.Containers, prefix) + } + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.AppsV1().Deployments(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasEnvVarPrefixChanged(DeploymentContainers, prefix, priorValue), timeout) + return HandleWatchResult(err) +} + +// WaitPaused waits for the Deployment to have the paused annotation using watches. +func (a *DeploymentAdapter) WaitPaused(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.AppsV1().Deployments(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasAnnotation(DeploymentAnnotations, annotationKey), timeout) + return HandleWatchResult(err) +} + +// WaitUnpaused waits for the Deployment to NOT have the paused annotation using watches. +func (a *DeploymentAdapter) WaitUnpaused(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.AppsV1().Deployments(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, NoAnnotation(DeploymentAnnotations, annotationKey), timeout) + return HandleWatchResult(err) +} + +// SupportsEnvVarStrategy returns true as Deployments support env var reload strategy. +func (a *DeploymentAdapter) SupportsEnvVarStrategy() bool { + return true +} + +// RequiresSpecialHandling returns false as Deployments use standard rolling restart. +func (a *DeploymentAdapter) RequiresSpecialHandling() bool { + return false +} + +// GetPodTemplateAnnotation returns the value of a pod template annotation. +func (a *DeploymentAdapter) GetPodTemplateAnnotation(ctx context.Context, namespace, name, annotationKey string) (string, error) { + deploy, err := a.client.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", err + } + return deploy.Spec.Template.Annotations[annotationKey], nil +} + +// buildDeploymentOptions converts WorkloadConfig to DeploymentOption slice. +func buildDeploymentOptions(cfg WorkloadConfig) []DeploymentOption { + return []DeploymentOption{ + func(d *appsv1.Deployment) { + if len(cfg.Annotations) > 0 { + if d.Annotations == nil { + d.Annotations = make(map[string]string) + } + for k, v := range cfg.Annotations { + d.Annotations[k] = v + } + } + ApplyWorkloadConfig(&d.Spec.Template, cfg) + }, + } +} diff --git a/test/e2e/utils/workload_job.go b/test/e2e/utils/workload_job.go new file mode 100644 index 00000000..e71c86c2 --- /dev/null +++ b/test/e2e/utils/workload_job.go @@ -0,0 +1,120 @@ +package utils + +import ( + "context" + "errors" + "time" + + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" +) + +// JobAdapter implements WorkloadAdapter for Kubernetes Jobs. +type JobAdapter struct { + client kubernetes.Interface +} + +// NewJobAdapter creates a new JobAdapter. +func NewJobAdapter(client kubernetes.Interface) *JobAdapter { + return &JobAdapter{client: client} +} + +// Type returns the workload type. +func (a *JobAdapter) Type() WorkloadType { + return WorkloadJob +} + +// Create creates a Job with the given config. +func (a *JobAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error { + opts := buildJobOptions(cfg) + _, err := CreateJob(ctx, a.client, namespace, name, opts...) + return err +} + +// Delete removes the Job. +func (a *JobAdapter) Delete(ctx context.Context, namespace, name string) error { + return DeleteJob(ctx, a.client, namespace, name) +} + +// WaitReady waits for the Job to be ready (has active or succeeded pods) using watches. +func (a *JobAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.BatchV1().Jobs(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, IsReady(JobIsReady), timeout) + return err +} + +// WaitReloaded returns an error because Jobs are recreated, not updated. +// Use the Recreatable interface (GetOriginalUID + WaitRecreated) instead. +func (a *JobAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) { + return false, ErrUnsupportedOperation +} + +// WaitEnvVar returns an error because Jobs don't support env var reload strategy. +func (a *JobAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) { + return false, ErrUnsupportedOperation +} + +// WaitRecreated waits for the Job to be recreated with a different UID using watches. +func (a *JobAdapter) WaitRecreated(ctx context.Context, namespace, name, originalUID string, timeout time.Duration) (string, bool, error) { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.BatchV1().Jobs(namespace).Watch(ctx, opts) + } + job, err := WatchUntil(ctx, watchFunc, name, HasDifferentUID(JobUID, types.UID(originalUID)), timeout) + if errors.Is(err, ErrWatchTimeout) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return string(job.UID), true, nil +} + +// SupportsEnvVarStrategy returns false as Jobs don't support env var reload strategy. +func (a *JobAdapter) SupportsEnvVarStrategy() bool { + return false +} + +// RequiresSpecialHandling returns true as Jobs are recreated by Reloader. +func (a *JobAdapter) RequiresSpecialHandling() bool { + return true +} + +// GetOriginalUID retrieves the current UID of the Job for recreation verification. +func (a *JobAdapter) GetOriginalUID(ctx context.Context, namespace, name string) (string, error) { + job, err := a.client.BatchV1().Jobs(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", err + } + return string(job.UID), nil +} + +// GetPodTemplateAnnotation returns the value of a pod template annotation. +func (a *JobAdapter) GetPodTemplateAnnotation(ctx context.Context, namespace, name, annotationKey string) (string, error) { + job, err := a.client.BatchV1().Jobs(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", err + } + return job.Spec.Template.Annotations[annotationKey], nil +} + +// buildJobOptions converts WorkloadConfig to JobOption slice. +func buildJobOptions(cfg WorkloadConfig) []JobOption { + return []JobOption{ + func(job *batchv1.Job) { + if len(cfg.Annotations) > 0 { + if job.Annotations == nil { + job.Annotations = make(map[string]string) + } + for k, v := range cfg.Annotations { + job.Annotations[k] = v + } + } + ApplyWorkloadConfig(&job.Spec.Template, cfg) + }, + } +} diff --git a/test/e2e/utils/workload_openshift.go b/test/e2e/utils/workload_openshift.go new file mode 100644 index 00000000..6f758bf4 --- /dev/null +++ b/test/e2e/utils/workload_openshift.go @@ -0,0 +1,149 @@ +package utils + +import ( + "context" + "time" + + openshiftappsv1 "github.com/openshift/api/apps/v1" + openshiftclient "github.com/openshift/client-go/apps/clientset/versioned" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +// DeploymentConfigOption is a function that modifies a DeploymentConfig. +type DeploymentConfigOption func(*openshiftappsv1.DeploymentConfig) + +// DeploymentConfigAdapter implements WorkloadAdapter for OpenShift DeploymentConfigs. +type DeploymentConfigAdapter struct { + openshiftClient openshiftclient.Interface +} + +// NewDeploymentConfigAdapter creates a new DeploymentConfigAdapter. +func NewDeploymentConfigAdapter(openshiftClient openshiftclient.Interface) *DeploymentConfigAdapter { + return &DeploymentConfigAdapter{ + openshiftClient: openshiftClient, + } +} + +// Type returns the workload type. +func (a *DeploymentConfigAdapter) Type() WorkloadType { + return WorkloadDeploymentConfig +} + +// Create creates a DeploymentConfig with the given config. +func (a *DeploymentConfigAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error { + dc := baseDeploymentConfig(name) + opts := buildDeploymentConfigOptions(cfg) + for _, opt := range opts { + opt(dc) + } + _, err := a.openshiftClient.AppsV1().DeploymentConfigs(namespace).Create(ctx, dc, metav1.CreateOptions{}) + return err +} + +// Delete removes the DeploymentConfig. +func (a *DeploymentConfigAdapter) Delete(ctx context.Context, namespace, name string) error { + return a.openshiftClient.AppsV1().DeploymentConfigs(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// WaitReady waits for the DeploymentConfig to be ready using watches. +func (a *DeploymentConfigAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.openshiftClient.AppsV1().DeploymentConfigs(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, IsReady(DeploymentConfigIsReady), timeout) + return err +} + +// WaitReloaded waits for the DeploymentConfig to have the reload annotation using watches. +// Captures the current annotation value first to avoid false positives from prior reloads. +func (a *DeploymentConfigAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) { + priorValue, _ := a.GetPodTemplateAnnotation(ctx, namespace, name, annotationKey) + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.openshiftClient.AppsV1().DeploymentConfigs(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasPodTemplateAnnotationChanged(DeploymentConfigPodTemplate, annotationKey, priorValue), timeout) + return HandleWatchResult(err) +} + +// WaitEnvVar waits for the DeploymentConfig to have a STAKATER_ env var using watches. +// Captures the current env var value first to avoid false positives from prior reloads. +func (a *DeploymentConfigAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) { + priorValue := "" + if dc, err := a.openshiftClient.AppsV1().DeploymentConfigs(namespace).Get(ctx, name, metav1.GetOptions{}); err == nil && dc.Spec.Template != nil { + priorValue = GetEnvVarValueByPrefix(dc.Spec.Template.Spec.Containers, prefix) + } + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.openshiftClient.AppsV1().DeploymentConfigs(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasEnvVarPrefixChanged(DeploymentConfigContainers, prefix, priorValue), timeout) + return HandleWatchResult(err) +} + +// SupportsEnvVarStrategy returns true as DeploymentConfigs support env var reload strategy. +func (a *DeploymentConfigAdapter) SupportsEnvVarStrategy() bool { + return true +} + +// RequiresSpecialHandling returns false as DeploymentConfigs use standard rolling restart. +func (a *DeploymentConfigAdapter) RequiresSpecialHandling() bool { + return false +} + +// GetPodTemplateAnnotation returns the value of a pod template annotation. +func (a *DeploymentConfigAdapter) GetPodTemplateAnnotation(ctx context.Context, namespace, name, annotationKey string) (string, error) { + dc, err := a.openshiftClient.AppsV1().DeploymentConfigs(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", err + } + if dc.Spec.Template == nil { + return "", nil + } + return dc.Spec.Template.Annotations[annotationKey], nil +} + +// baseDeploymentConfig returns a minimal DeploymentConfig template. +func baseDeploymentConfig(name string) *openshiftappsv1.DeploymentConfig { + return &openshiftappsv1.DeploymentConfig{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: openshiftappsv1.DeploymentConfigSpec{ + Replicas: 1, + Selector: map[string]string{"app": name}, + Template: &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "main", + Image: DefaultImage, + Command: []string{"sh", "-c", DefaultCommand}, + }}, + }, + }, + Triggers: openshiftappsv1.DeploymentTriggerPolicies{ + {Type: openshiftappsv1.DeploymentTriggerOnConfigChange}, + }, + }, + } +} + +// buildDeploymentConfigOptions converts WorkloadConfig to DeploymentConfigOption slice. +func buildDeploymentConfigOptions(cfg WorkloadConfig) []DeploymentConfigOption { + return []DeploymentConfigOption{ + func(dc *openshiftappsv1.DeploymentConfig) { + if len(cfg.Annotations) > 0 { + if dc.Annotations == nil { + dc.Annotations = make(map[string]string) + } + for k, v := range cfg.Annotations { + dc.Annotations[k] = v + } + } + if dc.Spec.Template != nil { + ApplyWorkloadConfig(dc.Spec.Template, cfg) + } + }, + } +} diff --git a/test/e2e/utils/workload_statefulset.go b/test/e2e/utils/workload_statefulset.go new file mode 100644 index 00000000..d071678a --- /dev/null +++ b/test/e2e/utils/workload_statefulset.go @@ -0,0 +1,108 @@ +package utils + +import ( + "context" + "time" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" +) + +// StatefulSetAdapter implements WorkloadAdapter for Kubernetes StatefulSets. +type StatefulSetAdapter struct { + client kubernetes.Interface +} + +// NewStatefulSetAdapter creates a new StatefulSetAdapter. +func NewStatefulSetAdapter(client kubernetes.Interface) *StatefulSetAdapter { + return &StatefulSetAdapter{client: client} +} + +// Type returns the workload type. +func (a *StatefulSetAdapter) Type() WorkloadType { + return WorkloadStatefulSet +} + +// Create creates a StatefulSet with the given config. +func (a *StatefulSetAdapter) Create(ctx context.Context, namespace, name string, cfg WorkloadConfig) error { + opts := buildStatefulSetOptions(cfg) + _, err := CreateStatefulSet(ctx, a.client, namespace, name, opts...) + return err +} + +// Delete removes the StatefulSet. +func (a *StatefulSetAdapter) Delete(ctx context.Context, namespace, name string) error { + return DeleteStatefulSet(ctx, a.client, namespace, name) +} + +// WaitReady waits for the StatefulSet to be ready using watches. +func (a *StatefulSetAdapter) WaitReady(ctx context.Context, namespace, name string, timeout time.Duration) error { + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.AppsV1().StatefulSets(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, IsReady(StatefulSetIsReady), timeout) + return err +} + +// WaitReloaded waits for the StatefulSet to have the reload annotation using watches. +// Captures the current annotation value first to avoid false positives from prior reloads. +func (a *StatefulSetAdapter) WaitReloaded(ctx context.Context, namespace, name, annotationKey string, timeout time.Duration) (bool, error) { + priorValue, _ := a.GetPodTemplateAnnotation(ctx, namespace, name, annotationKey) + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.AppsV1().StatefulSets(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasPodTemplateAnnotationChanged(StatefulSetPodTemplate, annotationKey, priorValue), timeout) + return HandleWatchResult(err) +} + +// WaitEnvVar waits for the StatefulSet to have a STAKATER_ env var using watches. +// Captures the current env var value first to avoid false positives from prior reloads. +func (a *StatefulSetAdapter) WaitEnvVar(ctx context.Context, namespace, name, prefix string, timeout time.Duration) (bool, error) { + priorValue := "" + if sts, err := a.client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{}); err == nil { + priorValue = GetEnvVarValueByPrefix(sts.Spec.Template.Spec.Containers, prefix) + } + watchFunc := func(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return a.client.AppsV1().StatefulSets(namespace).Watch(ctx, opts) + } + _, err := WatchUntil(ctx, watchFunc, name, HasEnvVarPrefixChanged(StatefulSetContainers, prefix, priorValue), timeout) + return HandleWatchResult(err) +} + +// SupportsEnvVarStrategy returns true as StatefulSets support env var reload strategy. +func (a *StatefulSetAdapter) SupportsEnvVarStrategy() bool { + return true +} + +// RequiresSpecialHandling returns false as StatefulSets use standard rolling restart. +func (a *StatefulSetAdapter) RequiresSpecialHandling() bool { + return false +} + +// GetPodTemplateAnnotation returns the value of a pod template annotation. +func (a *StatefulSetAdapter) GetPodTemplateAnnotation(ctx context.Context, namespace, name, annotationKey string) (string, error) { + sts, err := a.client.AppsV1().StatefulSets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", err + } + return sts.Spec.Template.Annotations[annotationKey], nil +} + +// buildStatefulSetOptions converts WorkloadConfig to StatefulSetOption slice. +func buildStatefulSetOptions(cfg WorkloadConfig) []StatefulSetOption { + return []StatefulSetOption{ + func(sts *appsv1.StatefulSet) { + if len(cfg.Annotations) > 0 { + if sts.Annotations == nil { + sts.Annotations = make(map[string]string) + } + for k, v := range cfg.Annotations { + sts.Annotations[k] = v + } + } + ApplyWorkloadConfig(&sts.Spec.Template, cfg) + }, + } +} diff --git a/test/loadtest/README.md b/test/loadtest/README.md new file mode 100644 index 00000000..7182bb3a --- /dev/null +++ b/test/loadtest/README.md @@ -0,0 +1,544 @@ +# Reloader Load Test Framework + +This framework provides A/B comparison testing between two Reloader container images. + +## Overview + +The load test framework: +1. Creates a local kind cluster (1 control-plane + 6 worker nodes) +2. Deploys Prometheus for metrics collection +3. Loads the provided Reloader container images into the cluster +4. Runs standardized test scenarios (S1-S13) +5. Collects metrics via Prometheus scraping +6. Generates comparison reports with pass/fail criteria + +## Prerequisites + +- Docker or Podman +- kind (Kubernetes in Docker) +- kubectl +- Go 1.22+ + +## Building + +```bash +cd test/loadtest +go build -o loadtest ./cmd/loadtest +``` + +## Quick Start + +```bash +# Compare two published images (e.g., different versions) +./loadtest run \ + --old-image=stakater/reloader:v1.0.0 \ + --new-image=stakater/reloader:v1.1.0 + +# Run a specific scenario +./loadtest run \ + --old-image=stakater/reloader:v1.0.0 \ + --new-image=stakater/reloader:v1.1.0 \ + --scenario=S2 \ + --duration=120 + +# Test only a single image (no comparison) +./loadtest run --new-image=myregistry/reloader:dev + +# Use local images built with docker/podman +./loadtest run \ + --old-image=localhost/reloader:baseline \ + --new-image=localhost/reloader:feature-branch + +# Skip cluster creation (use existing kind cluster) +./loadtest run \ + --old-image=stakater/reloader:v1.0.0 \ + --new-image=stakater/reloader:v1.1.0 \ + --skip-cluster + +# Run all scenarios in parallel on 4 clusters (faster execution) +./loadtest run \ + --new-image=localhost/reloader:dev \ + --parallelism=4 + +# Run all 13 scenarios in parallel (one cluster per scenario) +./loadtest run \ + --new-image=localhost/reloader:dev \ + --parallelism=13 + +# Generate report from existing results +./loadtest report --scenario=S2 --results-dir=./results +``` + +## Command Line Options + +### Run Command + +| Option | Description | Default | +|--------|-------------|---------| +| `--old-image=IMAGE` | Container image for "old" version | - | +| `--new-image=IMAGE` | Container image for "new" version | - | +| `--scenario=ID` | Test scenario: S1-S13 or "all" | all | +| `--duration=SECONDS` | Test duration in seconds | 60 | +| `--parallelism=N` | Run N scenarios in parallel on N kind clusters | 1 | +| `--skip-cluster` | Skip kind cluster creation (use existing, only for parallelism=1) | false | +| `--results-dir=DIR` | Directory for results | ./results | + +**Note:** At least one of `--old-image` or `--new-image` is required. Provide both for A/B comparison. + +### Report Command + +| Option | Description | Default | +|--------|-------------|---------| +| `--scenario=ID` | Scenario to report on (required) | - | +| `--results-dir=DIR` | Directory containing results | ./results | +| `--output=FILE` | Output file (default: stdout) | - | + +## Test Scenarios + +| ID | Name | Description | +|-----|-----------------------|-------------------------------------------------| +| S1 | Burst Updates | Many ConfigMap/Secret updates in quick succession | +| S2 | Fan-Out | One ConfigMap used by many (50) workloads | +| S3 | High Cardinality | Many CMs/Secrets across many namespaces | +| S4 | No-Op Updates | Updates that don't change data (annotation only)| +| S5 | Workload Churn | Deployments created/deleted rapidly | +| S6 | Controller Restart | Restart controller pod under load | +| S7 | API Pressure | Many concurrent update requests | +| S8 | Large Objects | ConfigMaps > 100KB | +| S9 | Multi-Workload Types | Tests all workload types (Deploy, STS, DS) | +| S10 | Secrets + Mixed | Secrets and mixed ConfigMap+Secret workloads | +| S11 | Annotation Strategy | Tests `--reload-strategy=annotations` | +| S12 | Pause & Resume | Tests pause-period during rapid updates | +| S13 | Complex References | Init containers, valueFrom, projected volumes | + +## Metrics Reference + +This section explains each metric collected during load tests, what it measures, and what different values might indicate. + +### Counter Metrics (Totals) + +#### `reconcile_total` +**What it measures:** The total number of reconciliation loops executed by the controller. + +**What it indicates:** +- **Higher in new vs old:** The new controller-runtime implementation may batch events differently. This is often expected behavior, not a problem. +- **Lower in new vs old:** Better event batching/deduplication. Controller-runtime's work queue naturally deduplicates events. +- **Expected behavior:** The new implementation typically has *fewer* reconciles due to intelligent event batching. + +#### `action_total` +**What it measures:** The total number of reload actions triggered (rolling restarts of Deployments/StatefulSets/DaemonSets). + +**What it indicates:** +- **Should match expected value:** Both implementations should trigger the same number of reloads for the same workload. +- **Lower than expected:** Some updates were missed - potential bug or race condition. +- **Higher than expected:** Duplicate reloads triggered - inefficiency but not data loss. + +#### `reload_executed_total` +**What it measures:** Successful reload operations executed, labeled by `success=true/false`. + +**What it indicates:** +- **`success=true` count:** Number of workloads successfully restarted. +- **`success=false` count:** Failed restart attempts (API errors, permission issues). +- **Should match `action_total`:** If significantly lower, reloads are failing. + +#### `workloads_scanned_total` +**What it measures:** Number of workloads (Deployments, etc.) scanned when checking for ConfigMap/Secret references. + +**What it indicates:** +- **High count:** Controller is scanning many workloads per reconcile. +- **Expected behavior:** Should roughly match the number of workloads × number of reconciles. +- **Optimization signal:** If very high, namespace filtering or label selectors could help. + +#### `workloads_matched_total` +**What it measures:** Number of workloads that matched (reference the changed ConfigMap/Secret). + +**What it indicates:** +- **Should match `reload_executed_total`:** Every matched workload should be reloaded. +- **Higher than reloads:** Some matched workloads weren't reloaded (potential issue). + +#### `errors_total` +**What it measures:** Total errors encountered, labeled by error type. + +**What it indicates:** +- **Should be 0:** Any errors indicate problems. +- **Common causes:** API server timeouts, RBAC issues, resource conflicts. +- **Critical metric:** Non-zero errors in production should be investigated. + +### API Efficiency Metrics (REST Client) + +These metrics track Kubernetes API server calls made by Reloader. Lower values indicate more efficient operation with less API server load. + +#### `rest_client_requests_total` +**What it measures:** Total number of HTTP requests made to the Kubernetes API server. + +**What it indicates:** +- **Lower is better:** Fewer API calls means less load on the API server. +- **High count:** May indicate inefficient caching or excessive reconciles. +- **Comparison use:** Shows overall API efficiency between implementations. + +#### `rest_client_requests_get` +**What it measures:** Number of GET requests (fetching individual resources or listings). + +**What it indicates:** +- **Includes:** Fetching ConfigMaps, Secrets, Deployments, etc. +- **Higher count:** More frequent resource fetching, possibly due to cache misses. +- **Expected behavior:** Controller-runtime's caching should reduce GET requests compared to direct API calls. + +#### `rest_client_requests_patch` +**What it measures:** Number of PATCH requests (partial updates to resources). + +**What it indicates:** +- **Used for:** Rolling restart annotations on workloads. +- **Should correlate with:** `reload_executed_total` - each reload typically requires one PATCH. +- **Lower is better:** Fewer patches means more efficient batching or deduplication. + +#### `rest_client_requests_put` +**What it measures:** Number of PUT requests (full resource updates). + +**What it indicates:** +- **Used for:** Full object replacements (less common than PATCH). +- **Should be low:** Most updates use PATCH for efficiency. +- **High count:** May indicate suboptimal update strategy. + +#### `rest_client_requests_errors` +**What it measures:** Number of failed API requests (4xx/5xx responses). + +**What it indicates:** +- **Should be 0:** Errors indicate API server issues or permission problems. +- **Common causes:** Rate limiting, RBAC issues, resource conflicts, network issues. +- **Non-zero:** Investigate API server logs and Reloader permissions. + +### Latency Metrics (Percentiles) + +All latency metrics are reported in **seconds**. The report shows p50 (median), p95, and p99 percentiles. + +#### `reconcile_duration (s)` +**What it measures:** Time spent inside each reconcile loop, from start to finish. + +**What it indicates:** +- **p50 (median):** Typical reconcile time. Should be < 100ms for good performance. +- **p95:** 95th percentile - only 5% of reconciles take longer than this. +- **p99:** 99th percentile - indicates worst-case performance. + +**Interpreting differences:** +- **New higher than old:** Controller-runtime reconciles may do more work per loop but run fewer times. Check `reconcile_total` - if it's lower, this is expected. +- **Minor differences (< 0.5s absolute):** Not significant for sub-second values. + +#### `action_latency (s)` +**What it measures:** End-to-end time from ConfigMap/Secret change detection to workload restart triggered. + +**What it indicates:** +- **This is the user-facing latency:** How long users wait for their config changes to take effect. +- **p50 < 1s:** Excellent - most changes apply within a second. +- **p95 < 5s:** Good - even under load, changes apply quickly. +- **p99 > 10s:** May need investigation - some changes take too long. + +**What affects this:** +- API server responsiveness +- Number of workloads to scan +- Concurrent updates competing for resources + +### Understanding the Report + +#### Report Columns + +``` +Metric Old New Expected Old✓ New✓ Status +------ --- --- -------- ---- ---- ------ +action_total 100.00 100.00 100 ✓ ✓ pass +action_latency_p95 (s) 0.15 0.04 - - - pass +``` + +- **Old/New:** Measured values from each implementation +- **Expected:** Known expected value (for throughput metrics) +- **Old✓/New✓:** Whether the value is within 15% of expected (✓ = yes, ✗ = no, - = no expected value) +- **Status:** pass/fail based on comparison thresholds + +#### Pass/Fail Logic + +| Metric Type | Pass Condition | +|-------------|----------------| +| Throughput (action_total, reload_executed_total) | New value within 15% of expected | +| Latency (p50, p95, p99) | New not more than threshold% worse than old, OR absolute difference < minimum threshold | +| Errors | New ≤ Old (ideally both 0) | +| API Efficiency (rest_client_requests_*) | New ≤ Old (lower is better), or New not more than 50% higher | + +#### Latency Thresholds + +Latency comparisons use both percentage AND absolute thresholds to avoid false failures: + +| Metric | Max % Worse | Min Absolute Diff | +|--------|-------------|-------------------| +| p50 | 100% | 0.5s | +| p95 | 100% | 1.0s | +| p99 | 100% | 1.0s | + +**Example:** If old p50 = 0.01s and new p50 = 0.08s: +- Percentage difference: +700% (would fail % check) +- Absolute difference: 0.07s (< 0.5s threshold) +- **Result: PASS** (both values are fast enough that the difference doesn't matter) + +### Resource Consumption Metrics + +These metrics track CPU, memory, and Go runtime resource usage. Lower values generally indicate more efficient operation. + +#### Memory Metrics + +| Metric | Description | Unit | +|--------|-------------|------| +| `memory_rss_mb_avg` | Average RSS (resident set size) memory | MB | +| `memory_rss_mb_max` | Peak RSS memory during test | MB | +| `memory_heap_mb_avg` | Average Go heap allocation | MB | +| `memory_heap_mb_max` | Peak Go heap allocation | MB | + +**What to watch for:** +- **High RSS:** May indicate memory leaks or inefficient caching +- **High heap:** Many objects being created (check GC metrics) +- **Growing over time:** Potential memory leak + +#### CPU Metrics + +| Metric | Description | Unit | +|--------|-------------|------| +| `cpu_cores_avg` | Average CPU usage rate | cores | +| `cpu_cores_max` | Peak CPU usage rate | cores | + +**What to watch for:** +- **High CPU:** Inefficient algorithms or excessive reconciles +- **Spiky max:** May indicate burst handling issues + +#### Go Runtime Metrics + +| Metric | Description | Unit | +|--------|-------------|------| +| `goroutines_avg` | Average goroutine count | count | +| `goroutines_max` | Peak goroutine count | count | +| `gc_pause_p99_ms` | 99th percentile GC pause time | ms | + +**What to watch for:** +- **High goroutines:** Potential goroutine leak or unbounded concurrency +- **High GC pause:** Large heap or allocation pressure + +### Scenario-Specific Expectations + +| Scenario | Key Metrics to Watch | Expected Behavior | +|----------|---------------------|-------------------| +| S1 (Burst) | action_latency_p99, cpu_cores_max, goroutines_max | Should handle bursts without queue backup | +| S2 (Fan-Out) | reconcile_total, workloads_matched, memory_rss_mb_max | One CM change → 50 workload reloads | +| S3 (High Cardinality) | reconcile_duration, memory_heap_mb_avg | Many namespaces shouldn't increase memory | +| S4 (No-Op) | action_total = 0, cpu_cores_avg should be low | Minimal resource usage for no-op | +| S5 (Churn) | errors_total, goroutines_avg | Graceful handling, no goroutine leak | +| S6 (Restart) | All metrics captured | Metrics survive controller restart | +| S7 (API Pressure) | errors_total, cpu_cores_max, goroutines_max | No errors under concurrent load | +| S8 (Large Objects) | memory_rss_mb_max, gc_pause_p99_ms | Large ConfigMaps don't cause OOM or GC issues | +| S9 (Multi-Workload) | reload_executed_total per type | All workload types (Deploy, STS, DS) reload | +| S10 (Secrets) | reload_executed_total, workloads_matched | Both Secrets and ConfigMaps trigger reloads | +| S11 (Annotation) | workload annotations present | Deployments get `last-reloaded-from` annotation | +| S12 (Pause) | reload_executed_total << updates | Pause-period reduces reload frequency | +| S13 (Complex) | reload_executed_total | All reference types trigger reloads | + +### Troubleshooting + +#### New implementation shows 0 for all metrics +- Check if Prometheus is scraping the new Reloader pod +- Verify pod annotations: `prometheus.io/scrape: "true"` +- Check Prometheus targets: `http://localhost:9091/targets` + +#### Metrics don't match expected values +- Verify test ran to completion (check logs) +- Ensure Prometheus scraped final metrics (18s wait after test) +- Check for pod restarts during test (metrics reset on restart - handled by `increase()`) + +#### High latency in new implementation +- Check Reloader pod resource limits +- Look for API server throttling in logs +- Compare `reconcile_total` - fewer reconciles with higher duration may be normal + +#### REST client errors are non-zero +- **Common causes:** + - Optional CRD schemes registered but CRDs not installed (e.g., Argo Rollouts, OpenShift DeploymentConfig) + - API server rate limiting under high load + - RBAC permissions missing for certain resource types +- **Argo Rollouts errors:** If you see ~4 errors per test, ensure `--enable-argo-rollouts=false` if not using Argo Rollouts +- **OpenShift errors:** Similarly, ensure DeploymentConfig support is disabled on non-OpenShift clusters + +#### REST client requests much higher in new implementation +- Check if caching is working correctly +- Look for excessive re-queuing in controller logs +- Compare `reconcile_total` - more reconciles naturally means more API calls + +## Report Format + +The report generator produces a comparison table with units and expected value indicators: + +``` +================================================================================ + RELOADER A/B COMPARISON REPORT +================================================================================ + +Scenario: S2 +Generated: 2026-01-03 14:30:00 +Status: PASS +Summary: All metrics within acceptable thresholds + +Test: S2: Fan-out test - 1 CM update triggers 50 deployment reloads + +-------------------------------------------------------------------------------- + METRIC COMPARISONS +-------------------------------------------------------------------------------- +(Old✓/New✓ = meets expected value within 15%) + +Metric Old New Expected Old✓ New✓ Status +------ --- --- -------- ---- ---- ------ +reconcile_total 50.00 25.00 - - - pass +reconcile_duration_p50 (s) 0.01 0.05 - - - pass +reconcile_duration_p95 (s) 0.02 0.15 - - - pass +action_total 50.00 50.00 50 ✓ ✓ pass +action_latency_p50 (s) 0.05 0.03 - - - pass +action_latency_p95 (s) 0.12 0.08 - - - pass +errors_total 0.00 0.00 - - - pass +reload_executed_total 50.00 50.00 50 ✓ ✓ pass +workloads_scanned_total 50.00 50.00 50 ✓ ✓ pass +workloads_matched_total 50.00 50.00 50 ✓ ✓ pass +rest_client_requests_total 850 720 - - - pass +rest_client_requests_get 500 420 - - - pass +rest_client_requests_patch 300 250 - - - pass +rest_client_requests_errors 0 0 - - - pass +``` + +Reports are saved to `results//report.txt` after each test. + +## Directory Structure + +``` +test/loadtest/ +├── cmd/ +│ └── loadtest/ # Unified CLI (run + report) +│ └── main.go +├── internal/ +│ ├── cluster/ # Kind cluster management +│ │ └── kind.go +│ ├── prometheus/ # Prometheus deployment & querying +│ │ └── prometheus.go +│ ├── reloader/ # Reloader deployment +│ │ └── deploy.go +│ └── scenarios/ # Test scenario implementations +│ └── scenarios.go +├── manifests/ +│ └── prometheus.yaml # Prometheus deployment manifest +├── results/ # Generated after tests +│ └── / +│ ├── old/ # Old version data +│ │ ├── *.json # Prometheus metric snapshots +│ │ └── reloader.log # Reloader pod logs +│ ├── new/ # New version data +│ │ ├── *.json # Prometheus metric snapshots +│ │ └── reloader.log # Reloader pod logs +│ ├── expected.json # Expected values from test +│ └── report.txt # Comparison report +├── go.mod +├── go.sum +└── README.md +``` + +## Building Local Images for Testing + +If you want to test local code changes: + +```bash +# Build the new Reloader image from current source +docker build -t localhost/reloader:dev -f Dockerfile . + +# Build from a different branch/commit +git checkout feature-branch +docker build -t localhost/reloader:feature -f Dockerfile . + +# Then run comparison +./loadtest run \ + --old-image=stakater/reloader:v1.0.0 \ + --new-image=localhost/reloader:feature +``` + +## Interpreting Results + +### PASS +All metrics are within acceptable thresholds. The new implementation is comparable or better than the old one. + +### FAIL +One or more metrics exceeded thresholds. Review the specific metrics: +- **Latency degradation**: p95/p99 latencies are significantly higher +- **Missed reloads**: `reload_executed_total` differs significantly +- **Errors increased**: `errors_total` is higher in new version + +### Investigation + +If tests fail, check: +1. Pod logs: `kubectl logs -n reloader-new deployment/reloader` (or check `results//new/reloader.log`) +2. Resource usage: `kubectl top pods -n reloader-new` +3. Events: `kubectl get events -n reloader-test` + +## Parallel Execution + +The `--parallelism` option enables running scenarios on multiple kind clusters simultaneously, significantly reducing total test time. + +### How It Works + +1. **Multiple Clusters**: Creates N kind clusters named `reloader-loadtest-0`, `reloader-loadtest-1`, etc. +2. **Separate Prometheus**: Each cluster gets its own Prometheus instance with a unique port (9091, 9092, etc.) +3. **Worker Pool**: Scenarios are distributed to workers via a channel, with each worker running on its own cluster +4. **Independent Execution**: Each scenario runs in complete isolation with no resource contention + +### Usage + +```bash +# Run 4 scenarios at a time (creates 4 clusters) +./loadtest run --new-image=my-image:tag --parallelism=4 + +# Run all 13 scenarios in parallel (creates 13 clusters) +./loadtest run --new-image=my-image:tag --parallelism=13 --scenario=all +``` + +### Resource Requirements + +Parallel execution requires significant system resources: + +| Parallelism | Clusters | Est. Memory | Est. CPU | +|-------------|----------|-------------|----------| +| 1 (default) | 1 | ~4GB | 2-4 cores | +| 4 | 4 | ~16GB | 8-16 cores | +| 13 | 13 | ~52GB | 26-52 cores | + +### Notes + +- The `--skip-cluster` option is not supported with parallelism > 1 +- Each worker loads images independently, so initial setup takes longer +- All results are written to the same `--results-dir` with per-scenario subdirectories +- If a cluster setup fails, remaining workers continue with available clusters +- Parallelism automatically reduces to match scenario count if set higher + +## CI Integration + +### GitHub Actions + +Load tests can be triggered on pull requests by commenting `/loadtest`: + +``` +/loadtest +``` + +This will: +1. Build a container image from the PR branch +2. Run all load test scenarios against it +3. Post results as a PR comment +4. Upload detailed results as artifacts + +### Make Target + +Run load tests locally or in CI: + +```bash +# From repository root +make loadtest +``` + +This builds the container image and runs all scenarios with a 60-second duration. diff --git a/test/loadtest/cmd/loadtest/main.go b/test/loadtest/cmd/loadtest/main.go new file mode 100644 index 00000000..510ce0b4 --- /dev/null +++ b/test/loadtest/cmd/loadtest/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/stakater/Reloader/test/loadtest/internal/cmd" + +func main() { + cmd.Execute() +} diff --git a/test/loadtest/go.mod b/test/loadtest/go.mod new file mode 100644 index 00000000..08230ca1 --- /dev/null +++ b/test/loadtest/go.mod @@ -0,0 +1,52 @@ +module github.com/stakater/Reloader/test/loadtest + +go 1.26 + +require ( + github.com/spf13/cobra v1.8.1 + k8s.io/api v0.31.0 + k8s.io/apimachinery v0.31.0 + k8s.io/client-go v0.31.0 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.4 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/test/loadtest/go.sum b/test/loadtest/go.sum new file mode 100644 index 00000000..f4f0ad8d --- /dev/null +++ b/test/loadtest/go.sum @@ -0,0 +1,160 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= +github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= +github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= +k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= +k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= +k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= +k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/test/loadtest/internal/cluster/kind.go b/test/loadtest/internal/cluster/kind.go new file mode 100644 index 00000000..1fde3142 --- /dev/null +++ b/test/loadtest/internal/cluster/kind.go @@ -0,0 +1,314 @@ +// Package cluster provides kind cluster management functionality. +package cluster + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// Config holds configuration for kind cluster operations. +type Config struct { + Name string + ContainerRuntime string // "docker" or "podman" + PortOffset int // Offset for host port mappings (for parallel clusters) +} + +// Manager handles kind cluster operations. +type Manager struct { + cfg Config +} + +// NewManager creates a new cluster manager. +func NewManager(cfg Config) *Manager { + return &Manager{cfg: cfg} +} + +// DetectContainerRuntime finds available container runtime. +// It checks if the runtime daemon is actually running, not just if the binary exists. +func DetectContainerRuntime() (string, error) { + if _, err := exec.LookPath("docker"); err == nil { + cmd := exec.Command("docker", "info") + if err := cmd.Run(); err == nil { + return "docker", nil + } + } + if _, err := exec.LookPath("podman"); err == nil { + cmd := exec.Command("podman", "info") + if err := cmd.Run(); err == nil { + return "podman", nil + } + } + return "", fmt.Errorf("neither docker nor podman is running") +} + +// Exists checks if the cluster already exists. +func (m *Manager) Exists() bool { + cmd := exec.Command("kind", "get", "clusters") + out, err := cmd.Output() + if err != nil { + return false + } + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == m.cfg.Name { + return true + } + } + return false +} + +// Delete deletes the kind cluster. +func (m *Manager) Delete(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "kind", "delete", "cluster", "--name", m.cfg.Name) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// Create creates a new kind cluster with optimized settings. +func (m *Manager) Create(ctx context.Context) error { + if m.cfg.ContainerRuntime == "podman" { + os.Setenv("KIND_EXPERIMENTAL_PROVIDER", "podman") + } + + if m.Exists() { + fmt.Printf("Cluster %s already exists, deleting...\n", m.cfg.Name) + if err := m.Delete(ctx); err != nil { + return fmt.Errorf("deleting existing cluster: %w", err) + } + } + + httpPort := 8080 + m.cfg.PortOffset + httpsPort := 8443 + m.cfg.PortOffset + + config := fmt.Sprintf(`kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +networking: + podSubnet: "10.244.0.0/16" + serviceSubnet: "10.96.0.0/16" +nodes: +- role: control-plane + kubeadmConfigPatches: + - | + kind: InitConfiguration + nodeRegistration: + kubeletExtraArgs: + node-labels: "ingress-ready=true" + kube-api-qps: "50" + kube-api-burst: "100" + serialize-image-pulls: "false" + event-qps: "50" + event-burst: "100" + - | + kind: ClusterConfiguration + apiServer: + extraArgs: + max-requests-inflight: "800" + max-mutating-requests-inflight: "400" + watch-cache-sizes: "configmaps#1000,secrets#1000,pods#1000" + controllerManager: + extraArgs: + kube-api-qps: "200" + kube-api-burst: "200" + scheduler: + extraArgs: + kube-api-qps: "200" + kube-api-burst: "200" + extraPortMappings: + - containerPort: 80 + hostPort: %d + protocol: TCP + - containerPort: 443 + hostPort: %d + protocol: TCP +- role: worker + kubeadmConfigPatches: + - | + kind: JoinConfiguration + nodeRegistration: + kubeletExtraArgs: + max-pods: "250" + kube-api-qps: "50" + kube-api-burst: "100" + serialize-image-pulls: "false" + event-qps: "50" + event-burst: "100" +- role: worker + kubeadmConfigPatches: + - | + kind: JoinConfiguration + nodeRegistration: + kubeletExtraArgs: + max-pods: "250" + kube-api-qps: "50" + kube-api-burst: "100" + serialize-image-pulls: "false" + event-qps: "50" + event-burst: "100" +- role: worker + kubeadmConfigPatches: + - | + kind: JoinConfiguration + nodeRegistration: + kubeletExtraArgs: + max-pods: "250" + kube-api-qps: "50" + kube-api-burst: "100" + serialize-image-pulls: "false" + event-qps: "50" + event-burst: "100" +- role: worker + kubeadmConfigPatches: + - | + kind: JoinConfiguration + nodeRegistration: + kubeletExtraArgs: + max-pods: "250" + kube-api-qps: "50" + kube-api-burst: "100" + serialize-image-pulls: "false" + event-qps: "50" + event-burst: "100" +- role: worker + kubeadmConfigPatches: + - | + kind: JoinConfiguration + nodeRegistration: + kubeletExtraArgs: + max-pods: "250" + kube-api-qps: "50" + kube-api-burst: "100" + serialize-image-pulls: "false" + event-qps: "50" + event-burst: "100" +- role: worker + kubeadmConfigPatches: + - | + kind: JoinConfiguration + nodeRegistration: + kubeletExtraArgs: + max-pods: "250" + kube-api-qps: "50" + kube-api-burst: "100" + serialize-image-pulls: "false" + event-qps: "50" + event-burst: "100" +`, httpPort, httpsPort) + cmd := exec.CommandContext(ctx, "kind", "create", "cluster", "--name", m.cfg.Name, "--config=-") + cmd.Stdin = strings.NewReader(config) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// GetKubeconfig returns the kubeconfig for the cluster. +func (m *Manager) GetKubeconfig() (string, error) { + cmd := exec.Command("kind", "get", "kubeconfig", "--name", m.cfg.Name) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("getting kubeconfig: %w", err) + } + return string(out), nil +} + +// Context returns the kubectl context name for this cluster. +func (m *Manager) Context() string { + return "kind-" + m.cfg.Name +} + +// Name returns the cluster name. +func (m *Manager) Name() string { + return m.cfg.Name +} + +// LoadImage loads a container image into the kind cluster. +func (m *Manager) LoadImage(ctx context.Context, image string) error { + if !m.imageExistsLocally(image) { + fmt.Printf(" Image not found locally, pulling: %s\n", image) + pullCmd := exec.CommandContext(ctx, m.cfg.ContainerRuntime, "pull", image) + pullCmd.Stdout = os.Stdout + pullCmd.Stderr = os.Stderr + if err := pullCmd.Run(); err != nil { + return fmt.Errorf("pulling image %s: %w", image, err) + } + } else { + fmt.Printf(" Image found locally: %s\n", image) + } + + fmt.Printf(" Copying image to kind cluster...\n") + + if m.cfg.ContainerRuntime == "podman" { + tmpFile := fmt.Sprintf("/tmp/kind-image-%d.tar", time.Now().UnixNano()) + defer os.Remove(tmpFile) + + saveCmd := exec.CommandContext(ctx, m.cfg.ContainerRuntime, "save", image, "-o", tmpFile) + if err := saveCmd.Run(); err != nil { + return fmt.Errorf("saving image %s: %w", image, err) + } + + loadCmd := exec.CommandContext(ctx, "kind", "load", "image-archive", tmpFile, "--name", m.cfg.Name) + loadCmd.Stdout = os.Stdout + loadCmd.Stderr = os.Stderr + if err := loadCmd.Run(); err != nil { + return fmt.Errorf("loading image archive: %w", err) + } + } else { + loadCmd := exec.CommandContext(ctx, "kind", "load", "docker-image", image, "--name", m.cfg.Name) + loadCmd.Stdout = os.Stdout + loadCmd.Stderr = os.Stderr + if err := loadCmd.Run(); err != nil { + return fmt.Errorf("loading image %s: %w", image, err) + } + } + + return nil +} + +// imageExistsLocally checks if an image exists in the local container runtime. +func (m *Manager) imageExistsLocally(image string) bool { + cmd := exec.Command(m.cfg.ContainerRuntime, "image", "exists", image) + if err := cmd.Run(); err == nil { + return true + } + + cmd = exec.Command(m.cfg.ContainerRuntime, "image", "inspect", image) + if err := cmd.Run(); err == nil { + return true + } + + cmd = exec.Command(m.cfg.ContainerRuntime, "images", "--format", "{{.Repository}}:{{.Tag}}") + out, err := cmd.Output() + if err == nil { + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == image { + return true + } + } + } + + return false +} + +// PullImage pulls an image using the container runtime. +func (m *Manager) PullImage(ctx context.Context, image string) error { + cmd := exec.CommandContext(ctx, m.cfg.ContainerRuntime, "pull", image) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// ExecKubectl runs a kubectl command against the cluster. +func (m *Manager) ExecKubectl(ctx context.Context, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "kubectl", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("%w: %s", err, stderr.String()) + } + return stdout.Bytes(), nil +} diff --git a/test/loadtest/internal/cmd/report.go b/test/loadtest/internal/cmd/report.go new file mode 100644 index 00000000..87e4e26e --- /dev/null +++ b/test/loadtest/internal/cmd/report.go @@ -0,0 +1,860 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "log" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" +) + +var ( + reportScenario string + reportResultsDir string + reportOutputFile string + reportFormat string +) + +var reportCmd = &cobra.Command{ + Use: "report", + Short: "Generate comparison report for a scenario", + Long: `Generate a detailed report for a specific test scenario. + +Examples: + # Generate report for a scenario + loadtest report --scenario=S2 --results-dir=./results + + # Generate JSON report + loadtest report --scenario=S2 --format=json`, + Run: func(cmd *cobra.Command, args []string) { + reportCommand() + }, +} + +func init() { + reportCmd.Flags().StringVar(&reportScenario, "scenario", "", "Scenario to report on (required)") + reportCmd.Flags().StringVar(&reportResultsDir, "results-dir", "./results", "Directory containing results") + reportCmd.Flags().StringVar(&reportOutputFile, "output", "", "Output file (default: stdout)") + reportCmd.Flags().StringVar(&reportFormat, "format", "text", "Output format: text, json, markdown") + reportCmd.MarkFlagRequired("scenario") +} + +// PrometheusResponse represents a Prometheus API response for report parsing. +type PrometheusResponse struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []struct { + Metric map[string]string `json:"metric"` + Value []interface{} `json:"value"` + } `json:"result"` + } `json:"data"` +} + +// MetricComparison represents the comparison of a single metric. +type MetricComparison struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Unit string `json:"unit"` + IsCounter bool `json:"is_counter"` + OldValue float64 `json:"old_value"` + NewValue float64 `json:"new_value"` + Expected float64 `json:"expected"` + Difference float64 `json:"difference"` + DiffPct float64 `json:"diff_pct"` + Status string `json:"status"` + Threshold float64 `json:"threshold"` + OldMeetsExpected string `json:"old_meets_expected"` + NewMeetsExpected string `json:"new_meets_expected"` +} + +type metricInfo struct { + unit string + isCounter bool +} + +var metricInfoMap = map[string]metricInfo{ + "reconcile_total": {unit: "count", isCounter: true}, + "reconcile_duration_p50": {unit: "s", isCounter: false}, + "reconcile_duration_p95": {unit: "s", isCounter: false}, + "reconcile_duration_p99": {unit: "s", isCounter: false}, + "action_total": {unit: "count", isCounter: true}, + "action_latency_p50": {unit: "s", isCounter: false}, + "action_latency_p95": {unit: "s", isCounter: false}, + "action_latency_p99": {unit: "s", isCounter: false}, + "errors_total": {unit: "count", isCounter: true}, + "reload_executed_total": {unit: "count", isCounter: true}, + "workloads_scanned_total": {unit: "count", isCounter: true}, + "workloads_matched_total": {unit: "count", isCounter: true}, + "skipped_total_no_data_change": {unit: "count", isCounter: true}, + "rest_client_requests_total": {unit: "count", isCounter: true}, + "rest_client_requests_get": {unit: "count", isCounter: true}, + "rest_client_requests_patch": {unit: "count", isCounter: true}, + "rest_client_requests_put": {unit: "count", isCounter: true}, + "rest_client_requests_errors": {unit: "count", isCounter: true}, + "memory_rss_mb_avg": {unit: "MB", isCounter: false}, + "memory_rss_mb_max": {unit: "MB", isCounter: false}, + "memory_heap_mb_avg": {unit: "MB", isCounter: false}, + "memory_heap_mb_max": {unit: "MB", isCounter: false}, + "cpu_cores_avg": {unit: "cores", isCounter: false}, + "cpu_cores_max": {unit: "cores", isCounter: false}, + "goroutines_avg": {unit: "count", isCounter: false}, + "goroutines_max": {unit: "count", isCounter: false}, + "gc_pause_p99_ms": {unit: "ms", isCounter: false}, +} + +// ReportExpectedMetrics matches the expected metrics from test scenarios. +type ReportExpectedMetrics struct { + ActionTotal int `json:"action_total"` + ReloadExecutedTotal int `json:"reload_executed_total"` + ReconcileTotal int `json:"reconcile_total"` + WorkloadsScannedTotal int `json:"workloads_scanned_total"` + WorkloadsMatchedTotal int `json:"workloads_matched_total"` + SkippedTotal int `json:"skipped_total"` + Description string `json:"description"` +} + +// ScenarioReport represents the full report for a scenario. +type ScenarioReport struct { + Scenario string `json:"scenario"` + Timestamp time.Time `json:"timestamp"` + Comparisons []MetricComparison `json:"comparisons"` + OverallStatus string `json:"overall_status"` + Summary string `json:"summary"` + PassCriteria []string `json:"pass_criteria"` + FailedCriteria []string `json:"failed_criteria"` + Expected ReportExpectedMetrics `json:"expected"` + TestDescription string `json:"test_description"` +} + +// MetricType defines how to evaluate a metric. +type MetricType int + +const ( + LowerIsBetter MetricType = iota + ShouldMatch + HigherIsBetter + Informational +) + +type thresholdConfig struct { + maxDiff float64 + metricType MetricType + minAbsDiff float64 +} + +var thresholds = map[string]thresholdConfig{ + "reconcile_total": {maxDiff: 60.0, metricType: LowerIsBetter}, + "reconcile_duration_p50": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 0.5}, + "reconcile_duration_p95": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 1.0}, + "reconcile_duration_p99": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 1.0}, + "action_latency_p50": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 0.5}, + "action_latency_p95": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 1.0}, + "action_latency_p99": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 1.0}, + "errors_total": {maxDiff: 0.0, metricType: LowerIsBetter}, + "action_total": {maxDiff: 15.0, metricType: ShouldMatch}, + "reload_executed_total": {maxDiff: 15.0, metricType: ShouldMatch}, + "workloads_scanned_total": {maxDiff: 15.0, metricType: ShouldMatch}, + "workloads_matched_total": {maxDiff: 15.0, metricType: ShouldMatch}, + "skipped_total_no_data_change": {maxDiff: 20.0, metricType: ShouldMatch}, + "rest_client_requests_total": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 50}, + "rest_client_requests_get": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 50}, + "rest_client_requests_patch": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 50}, + "rest_client_requests_put": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 20}, + "rest_client_requests_errors": {maxDiff: 0.0, metricType: LowerIsBetter, minAbsDiff: 100}, + "memory_rss_mb_avg": {maxDiff: 50.0, metricType: LowerIsBetter, minAbsDiff: 20}, + "memory_rss_mb_max": {maxDiff: 50.0, metricType: LowerIsBetter, minAbsDiff: 30}, + "memory_heap_mb_avg": {maxDiff: 50.0, metricType: LowerIsBetter, minAbsDiff: 15}, + "memory_heap_mb_max": {maxDiff: 50.0, metricType: LowerIsBetter, minAbsDiff: 20}, + "cpu_cores_avg": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 0.1}, + "cpu_cores_max": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 0.2}, + "goroutines_avg": {metricType: Informational}, + "goroutines_max": {metricType: Informational}, + "gc_pause_p99_ms": {maxDiff: 100.0, metricType: LowerIsBetter, minAbsDiff: 5}, +} + +func reportCommand() { + if reportScenario == "" { + log.Fatal("--scenario is required for report command") + } + + report, err := generateScenarioReport(reportScenario, reportResultsDir) + if err != nil { + log.Fatalf("Failed to generate report: %v", err) + } + + var output string + switch OutputFormat(reportFormat) { + case OutputFormatJSON: + output = renderScenarioReportJSON(report) + case OutputFormatMarkdown: + output = renderScenarioReportMarkdown(report) + default: + output = renderScenarioReport(report) + } + + if reportOutputFile != "" { + if err := os.WriteFile(reportOutputFile, []byte(output), 0644); err != nil { + log.Fatalf("Failed to write output file: %v", err) + } + log.Printf("Report written to %s", reportOutputFile) + } else { + fmt.Println(output) + } +} + +func generateScenarioReport(scenario, resultsDir string) (*ScenarioReport, error) { + oldDir := filepath.Join(resultsDir, scenario, "old") + newDir := filepath.Join(resultsDir, scenario, "new") + scenarioDir := filepath.Join(resultsDir, scenario) + + _, oldErr := os.Stat(oldDir) + _, newErr := os.Stat(newDir) + hasOld := oldErr == nil + hasNew := newErr == nil + isComparison := hasOld && hasNew + + singleVersion := "" + singleDir := "" + if !isComparison { + if hasNew { + singleVersion = "new" + singleDir = newDir + } else if hasOld { + singleVersion = "old" + singleDir = oldDir + } else { + return nil, fmt.Errorf("no results found in %s", scenarioDir) + } + } + + report := &ScenarioReport{ + Scenario: scenario, + Timestamp: time.Now(), + } + + expectedPath := filepath.Join(scenarioDir, "expected.json") + if data, err := os.ReadFile(expectedPath); err == nil { + if err := json.Unmarshal(data, &report.Expected); err != nil { + log.Printf("Warning: Could not parse expected metrics: %v", err) + } else { + report.TestDescription = report.Expected.Description + } + } + + if !isComparison { + return generateSingleVersionReport(report, singleDir, singleVersion, scenario) + } + + metricsToCompare := []struct { + name string + file string + selector func(data PrometheusResponse) float64 + }{ + {"reconcile_total", "reloader_reconcile_total.json", sumAllValues}, + {"reconcile_duration_p50", "reconcile_p50.json", getFirstValue}, + {"reconcile_duration_p95", "reconcile_p95.json", getFirstValue}, + {"reconcile_duration_p99", "reconcile_p99.json", getFirstValue}, + {"action_total", "reloader_action_total.json", sumAllValues}, + {"action_latency_p50", "action_p50.json", getFirstValue}, + {"action_latency_p95", "action_p95.json", getFirstValue}, + {"action_latency_p99", "action_p99.json", getFirstValue}, + {"errors_total", "reloader_errors_total.json", sumAllValues}, + {"reload_executed_total", "reloader_reload_executed_total.json", sumSuccessValues}, + {"workloads_scanned_total", "reloader_workloads_scanned_total.json", sumAllValues}, + {"workloads_matched_total", "reloader_workloads_matched_total.json", sumAllValues}, + {"rest_client_requests_total", "rest_client_requests_total.json", getFirstValue}, + {"rest_client_requests_get", "rest_client_requests_get.json", getFirstValue}, + {"rest_client_requests_patch", "rest_client_requests_patch.json", getFirstValue}, + {"rest_client_requests_put", "rest_client_requests_put.json", getFirstValue}, + {"rest_client_requests_errors", "rest_client_requests_errors.json", getFirstValue}, + {"memory_rss_mb_avg", "memory_rss_bytes_avg.json", bytesToMB}, + {"memory_rss_mb_max", "memory_rss_bytes_max.json", bytesToMB}, + {"memory_heap_mb_avg", "memory_heap_bytes_avg.json", bytesToMB}, + {"memory_heap_mb_max", "memory_heap_bytes_max.json", bytesToMB}, + {"cpu_cores_avg", "cpu_usage_cores_avg.json", getFirstValue}, + {"cpu_cores_max", "cpu_usage_cores_max.json", getFirstValue}, + {"goroutines_avg", "goroutines_avg.json", getFirstValue}, + {"goroutines_max", "goroutines_max.json", getFirstValue}, + {"gc_pause_p99_ms", "gc_duration_seconds_p99.json", secondsToMs}, + } + + expectedValues := map[string]float64{ + "action_total": float64(report.Expected.ActionTotal), + "reload_executed_total": float64(report.Expected.ReloadExecutedTotal), + "reconcile_total": float64(report.Expected.ReconcileTotal), + "workloads_scanned_total": float64(report.Expected.WorkloadsScannedTotal), + "workloads_matched_total": float64(report.Expected.WorkloadsMatchedTotal), + "skipped_total": float64(report.Expected.SkippedTotal), + } + + metricValues := make(map[string]struct{ old, new, expected float64 }) + + for _, m := range metricsToCompare { + oldData, err := loadMetricFile(filepath.Join(oldDir, m.file)) + if err != nil { + log.Printf("Warning: Could not load old metric %s: %v", m.name, err) + continue + } + + newData, err := loadMetricFile(filepath.Join(newDir, m.file)) + if err != nil { + log.Printf("Warning: Could not load new metric %s: %v", m.name, err) + continue + } + + oldValue := m.selector(oldData) + newValue := m.selector(newData) + expected := expectedValues[m.name] + + metricValues[m.name] = struct{ old, new, expected float64 }{oldValue, newValue, expected} + } + + newMeetsActionExpected := false + newReconcileIsZero := false + isChurnScenario := scenario == "S5" + if v, ok := metricValues["action_total"]; ok && v.expected > 0 { + tolerance := v.expected * 0.15 + newMeetsActionExpected = math.Abs(v.new-v.expected) <= tolerance + } + if v, ok := metricValues["reconcile_total"]; ok { + newReconcileIsZero = v.new == 0 + } + + for _, m := range metricsToCompare { + v, ok := metricValues[m.name] + if !ok { + continue + } + + comparison := compareMetricWithExpected(m.name, v.old, v.new, v.expected) + + if strings.HasPrefix(m.name, "rest_client_requests") { + if newMeetsActionExpected && comparison.Status != "pass" { + if oldMeets, ok := metricValues["action_total"]; ok { + oldTolerance := oldMeets.expected * 0.15 + oldMissed := math.Abs(oldMeets.old-oldMeets.expected) > oldTolerance + if oldMissed { + comparison.Status = "pass" + } + } + } + if newReconcileIsZero && comparison.Status != "pass" { + comparison.Status = "pass" + } + } + + if isChurnScenario { + if m.name == "errors_total" { + if v.new < 50 && v.old < 50 { + comparison.Status = "pass" + } else if v.new <= v.old*1.5 { + comparison.Status = "pass" + } + } + if m.name == "action_total" || m.name == "reload_executed_total" { + if v.old > 0 { + diff := math.Abs(v.new-v.old) / v.old * 100 + if diff <= 20 { + comparison.Status = "pass" + } + } else if v.new > 0 { + comparison.Status = "pass" + } + } + } + + report.Comparisons = append(report.Comparisons, comparison) + + if comparison.Status == "pass" { + report.PassCriteria = append(report.PassCriteria, m.name) + } else if comparison.Status == "fail" { + report.FailedCriteria = append(report.FailedCriteria, m.name) + } + } + + if len(report.FailedCriteria) == 0 { + report.OverallStatus = "PASS" + report.Summary = "All metrics within acceptable thresholds" + } else { + report.OverallStatus = "FAIL" + report.Summary = fmt.Sprintf("%d metrics failed: %s", + len(report.FailedCriteria), + strings.Join(report.FailedCriteria, ", ")) + } + + return report, nil +} + +func generateSingleVersionReport(report *ScenarioReport, dataDir, version, scenario string) (*ScenarioReport, error) { + metricsToCollect := []struct { + name string + file string + selector func(data PrometheusResponse) float64 + }{ + {"reconcile_total", "reloader_reconcile_total.json", sumAllValues}, + {"reconcile_duration_p50", "reconcile_p50.json", getFirstValue}, + {"reconcile_duration_p95", "reconcile_p95.json", getFirstValue}, + {"reconcile_duration_p99", "reconcile_p99.json", getFirstValue}, + {"action_total", "reloader_action_total.json", sumAllValues}, + {"action_latency_p50", "action_p50.json", getFirstValue}, + {"action_latency_p95", "action_p95.json", getFirstValue}, + {"action_latency_p99", "action_p99.json", getFirstValue}, + {"errors_total", "reloader_errors_total.json", sumAllValues}, + {"reload_executed_total", "reloader_reload_executed_total.json", sumSuccessValues}, + {"workloads_scanned_total", "reloader_workloads_scanned_total.json", sumAllValues}, + {"workloads_matched_total", "reloader_workloads_matched_total.json", sumAllValues}, + {"rest_client_requests_total", "rest_client_requests_total.json", getFirstValue}, + {"rest_client_requests_get", "rest_client_requests_get.json", getFirstValue}, + {"rest_client_requests_patch", "rest_client_requests_patch.json", getFirstValue}, + {"rest_client_requests_put", "rest_client_requests_put.json", getFirstValue}, + {"rest_client_requests_errors", "rest_client_requests_errors.json", getFirstValue}, + {"memory_rss_mb_avg", "memory_rss_bytes_avg.json", bytesToMB}, + {"memory_rss_mb_max", "memory_rss_bytes_max.json", bytesToMB}, + {"memory_heap_mb_avg", "memory_heap_bytes_avg.json", bytesToMB}, + {"memory_heap_mb_max", "memory_heap_bytes_max.json", bytesToMB}, + {"cpu_cores_avg", "cpu_usage_cores_avg.json", getFirstValue}, + {"cpu_cores_max", "cpu_usage_cores_max.json", getFirstValue}, + {"goroutines_avg", "goroutines_avg.json", getFirstValue}, + {"goroutines_max", "goroutines_max.json", getFirstValue}, + {"gc_pause_p99_ms", "gc_duration_seconds_p99.json", secondsToMs}, + } + + expectedValues := map[string]float64{ + "action_total": float64(report.Expected.ActionTotal), + "reload_executed_total": float64(report.Expected.ReloadExecutedTotal), + "reconcile_total": float64(report.Expected.ReconcileTotal), + "workloads_scanned_total": float64(report.Expected.WorkloadsScannedTotal), + "workloads_matched_total": float64(report.Expected.WorkloadsMatchedTotal), + "skipped_total": float64(report.Expected.SkippedTotal), + } + + for _, m := range metricsToCollect { + data, err := loadMetricFile(filepath.Join(dataDir, m.file)) + if err != nil { + log.Printf("Warning: Could not load metric %s: %v", m.name, err) + continue + } + + value := m.selector(data) + expected := expectedValues[m.name] + + info := metricInfoMap[m.name] + if info.unit == "" { + info = metricInfo{unit: "count", isCounter: true} + } + + displayName := m.name + if info.unit != "count" { + displayName = fmt.Sprintf("%s (%s)", m.name, info.unit) + } + + status := "info" + meetsExp := "-" + + if expected > 0 { + meetsExp = meetsExpected(value, expected) + threshold, ok := thresholds[m.name] + if ok && threshold.metricType == ShouldMatch { + if meetsExp == "✓" { + status = "pass" + report.PassCriteria = append(report.PassCriteria, m.name) + } else { + status = "fail" + report.FailedCriteria = append(report.FailedCriteria, m.name) + } + } + } + + if info.isCounter { + value = math.Round(value) + } + + report.Comparisons = append(report.Comparisons, MetricComparison{ + Name: m.name, + DisplayName: displayName, + Unit: info.unit, + IsCounter: info.isCounter, + OldValue: 0, + NewValue: value, + Expected: expected, + OldMeetsExpected: "-", + NewMeetsExpected: meetsExp, + Status: status, + }) + } + + if len(report.FailedCriteria) == 0 { + report.OverallStatus = "PASS" + report.Summary = fmt.Sprintf("Single-version test (%s) completed successfully", version) + } else { + report.OverallStatus = "FAIL" + report.Summary = fmt.Sprintf("%d metrics failed: %s", + len(report.FailedCriteria), + strings.Join(report.FailedCriteria, ", ")) + } + + return report, nil +} + +func loadMetricFile(path string) (PrometheusResponse, error) { + var resp PrometheusResponse + data, err := os.ReadFile(path) + if err != nil { + return resp, err + } + err = json.Unmarshal(data, &resp) + return resp, err +} + +func sumAllValues(data PrometheusResponse) float64 { + var sum float64 + for _, result := range data.Data.Result { + if len(result.Value) >= 2 { + if v, ok := result.Value[1].(string); ok { + var f float64 + fmt.Sscanf(v, "%f", &f) + sum += f + } + } + } + return sum +} + +func sumSuccessValues(data PrometheusResponse) float64 { + var sum float64 + for _, result := range data.Data.Result { + if result.Metric["success"] == "true" { + if len(result.Value) >= 2 { + if v, ok := result.Value[1].(string); ok { + var f float64 + fmt.Sscanf(v, "%f", &f) + sum += f + } + } + } + } + return sum +} + +func getFirstValue(data PrometheusResponse) float64 { + if len(data.Data.Result) > 0 && len(data.Data.Result[0].Value) >= 2 { + if v, ok := data.Data.Result[0].Value[1].(string); ok { + var f float64 + fmt.Sscanf(v, "%f", &f) + return f + } + } + return 0 +} + +func bytesToMB(data PrometheusResponse) float64 { + bytes := getFirstValue(data) + return bytes / (1024 * 1024) +} + +func secondsToMs(data PrometheusResponse) float64 { + seconds := getFirstValue(data) + return seconds * 1000 +} + +func meetsExpected(value, expected float64) string { + if expected == 0 { + return "-" + } + tolerance := expected * 0.15 + if math.Abs(value-expected) <= tolerance { + return "✓" + } + return "✗" +} + +func compareMetricWithExpected(name string, oldValue, newValue, expected float64) MetricComparison { + diff := newValue - oldValue + absDiff := math.Abs(diff) + var diffPct float64 + if oldValue != 0 { + diffPct = (diff / oldValue) * 100 + } else if newValue != 0 { + diffPct = 100 + } + + threshold, ok := thresholds[name] + if !ok { + threshold = thresholdConfig{maxDiff: 10.0, metricType: ShouldMatch} + } + + info := metricInfoMap[name] + if info.unit == "" { + info = metricInfo{unit: "count", isCounter: true} + } + displayName := name + if info.unit != "count" { + displayName = fmt.Sprintf("%s (%s)", name, info.unit) + } + + if info.isCounter { + oldValue = math.Round(oldValue) + newValue = math.Round(newValue) + } + + status := "pass" + oldMeetsExp := meetsExpected(oldValue, expected) + newMeetsExp := meetsExpected(newValue, expected) + + isNewMetric := info.isCounter && oldValue == 0 && newValue > 0 && expected == 0 + + if isNewMetric { + status = "info" + } else if expected > 0 && threshold.metricType == ShouldMatch { + if newMeetsExp == "✗" { + status = "fail" + } + } else { + switch threshold.metricType { + case LowerIsBetter: + if threshold.minAbsDiff > 0 && absDiff < threshold.minAbsDiff { + status = "pass" + } else if diffPct > threshold.maxDiff { + status = "fail" + } + case HigherIsBetter: + if diffPct < -threshold.maxDiff { + status = "fail" + } + case ShouldMatch: + if math.Abs(diffPct) > threshold.maxDiff { + status = "fail" + } + case Informational: + status = "info" + } + } + + return MetricComparison{ + Name: name, + DisplayName: displayName, + Unit: info.unit, + IsCounter: info.isCounter, + Expected: expected, + OldMeetsExpected: oldMeetsExp, + NewMeetsExpected: newMeetsExp, + OldValue: oldValue, + NewValue: newValue, + Difference: diff, + DiffPct: diffPct, + Status: status, + Threshold: threshold.maxDiff, + } +} + +func renderScenarioReport(report *ScenarioReport) string { + var sb strings.Builder + + isSingleVersion := true + for _, c := range report.Comparisons { + if c.OldValue != 0 { + isSingleVersion = false + break + } + } + + sb.WriteString("\n") + sb.WriteString("================================================================================\n") + if isSingleVersion { + sb.WriteString(" RELOADER TEST REPORT\n") + } else { + sb.WriteString(" RELOADER A/B COMPARISON REPORT\n") + } + sb.WriteString("================================================================================\n\n") + + fmt.Fprintf(&sb, "Scenario: %s\n", report.Scenario) + fmt.Fprintf(&sb, "Generated: %s\n", report.Timestamp.Format("2006-01-02 15:04:05")) + fmt.Fprintf(&sb, "Status: %s\n", report.OverallStatus) + fmt.Fprintf(&sb, "Summary: %s\n", report.Summary) + + if report.TestDescription != "" { + fmt.Fprintf(&sb, "Test: %s\n", report.TestDescription) + } + + if report.Expected.ActionTotal > 0 { + sb.WriteString("\n--------------------------------------------------------------------------------\n") + sb.WriteString(" EXPECTED VALUES\n") + sb.WriteString("--------------------------------------------------------------------------------\n") + fmt.Fprintf(&sb, "Expected Action Total: %d\n", report.Expected.ActionTotal) + fmt.Fprintf(&sb, "Expected Reload Executed Total: %d\n", report.Expected.ReloadExecutedTotal) + if report.Expected.SkippedTotal > 0 { + fmt.Fprintf(&sb, "Expected Skipped Total: %d\n", report.Expected.SkippedTotal) + } + } + + sb.WriteString("\n--------------------------------------------------------------------------------\n") + if isSingleVersion { + sb.WriteString(" METRICS\n") + } else { + sb.WriteString(" METRIC COMPARISONS\n") + } + sb.WriteString("--------------------------------------------------------------------------------\n") + + if isSingleVersion { + sb.WriteString("(✓ = meets expected value within 15%)\n\n") + fmt.Fprintf(&sb, "%-32s %12s %10s %5s %8s\n", + "Metric", "Value", "Expected", "Met?", "Status") + fmt.Fprintf(&sb, "%-32s %12s %10s %5s %8s\n", + "------", "-----", "--------", "----", "------") + + for _, c := range report.Comparisons { + if c.IsCounter { + if c.Expected > 0 { + fmt.Fprintf(&sb, "%-32s %12.0f %10.0f %5s %8s\n", + c.DisplayName, c.NewValue, c.Expected, + c.NewMeetsExpected, c.Status) + } else { + fmt.Fprintf(&sb, "%-32s %12.0f %10s %5s %8s\n", + c.DisplayName, c.NewValue, "-", + c.NewMeetsExpected, c.Status) + } + } else { + fmt.Fprintf(&sb, "%-32s %12.4f %10s %5s %8s\n", + c.DisplayName, c.NewValue, "-", + c.NewMeetsExpected, c.Status) + } + } + } else { + sb.WriteString("(Old✓/New✓ = meets expected value within 15%)\n\n") + + fmt.Fprintf(&sb, "%-32s %12s %12s %10s %5s %5s %8s\n", + "Metric", "Old", "New", "Expected", "Old✓", "New✓", "Status") + fmt.Fprintf(&sb, "%-32s %12s %12s %10s %5s %5s %8s\n", + "------", "---", "---", "--------", "----", "----", "------") + + for _, c := range report.Comparisons { + if c.IsCounter { + if c.Expected > 0 { + fmt.Fprintf(&sb, "%-32s %12.0f %12.0f %10.0f %5s %5s %8s\n", + c.DisplayName, c.OldValue, c.NewValue, c.Expected, + c.OldMeetsExpected, c.NewMeetsExpected, c.Status) + } else { + fmt.Fprintf(&sb, "%-32s %12.0f %12.0f %10s %5s %5s %8s\n", + c.DisplayName, c.OldValue, c.NewValue, "-", + c.OldMeetsExpected, c.NewMeetsExpected, c.Status) + } + } else { + fmt.Fprintf(&sb, "%-32s %12.4f %12.4f %10s %5s %5s %8s\n", + c.DisplayName, c.OldValue, c.NewValue, "-", + c.OldMeetsExpected, c.NewMeetsExpected, c.Status) + } + } + } + + sb.WriteString("\n--------------------------------------------------------------------------------\n") + sb.WriteString(" PASS/FAIL CRITERIA\n") + sb.WriteString("--------------------------------------------------------------------------------\n\n") + + fmt.Fprintf(&sb, "Passed (%d):\n", len(report.PassCriteria)) + for _, p := range report.PassCriteria { + fmt.Fprintf(&sb, " ✓ %s\n", p) + } + + if len(report.FailedCriteria) > 0 { + fmt.Fprintf(&sb, "\nFailed (%d):\n", len(report.FailedCriteria)) + for _, f := range report.FailedCriteria { + fmt.Fprintf(&sb, " ✗ %s\n", f) + } + } + + sb.WriteString("\n--------------------------------------------------------------------------------\n") + sb.WriteString(" THRESHOLDS USED\n") + sb.WriteString("--------------------------------------------------------------------------------\n\n") + + fmt.Fprintf(&sb, "%-35s %10s %15s %18s\n", + "Metric", "Max Diff%", "Min Abs Diff", "Direction") + fmt.Fprintf(&sb, "%-35s %10s %15s %18s\n", + "------", "---------", "------------", "---------") + + var names []string + for name := range thresholds { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + t := thresholds[name] + var direction string + switch t.metricType { + case LowerIsBetter: + direction = "lower is better" + case HigherIsBetter: + direction = "higher is better" + case ShouldMatch: + direction = "should match" + case Informational: + direction = "info only" + } + minAbsDiff := "-" + if t.minAbsDiff > 0 { + minAbsDiff = fmt.Sprintf("%.1f", t.minAbsDiff) + } + fmt.Fprintf(&sb, "%-35s %9.1f%% %15s %18s\n", + name, t.maxDiff, minAbsDiff, direction) + } + + sb.WriteString("\n================================================================================\n") + + return sb.String() +} + +func renderScenarioReportJSON(report *ScenarioReport) string { + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Sprintf(`{"error": "%s"}`, err.Error()) + } + return string(data) +} + +func renderScenarioReportMarkdown(report *ScenarioReport) string { + var sb strings.Builder + + emoji := "✅" + if report.OverallStatus != "PASS" { + emoji = "❌" + } + + sb.WriteString(fmt.Sprintf("## %s %s: %s\n\n", emoji, report.Scenario, report.OverallStatus)) + + if report.TestDescription != "" { + sb.WriteString(fmt.Sprintf("> %s\n\n", report.TestDescription)) + } + + sb.WriteString("| Metric | Value | Expected | Status |\n") + sb.WriteString("|--------|------:|:--------:|:------:|\n") + + keyMetrics := []string{"action_total", "reload_executed_total", "errors_total", "reconcile_total"} + for _, name := range keyMetrics { + for _, c := range report.Comparisons { + if c.Name == name { + value := fmt.Sprintf("%.0f", c.NewValue) + expected := "-" + if c.Expected > 0 { + expected = fmt.Sprintf("%.0f", c.Expected) + } + status := "✅" + if c.Status == "fail" { + status = "❌" + } else if c.Status == "info" { + status = "ℹ️" + } + sb.WriteString(fmt.Sprintf("| %s | %s | %s | %s |\n", c.DisplayName, value, expected, status)) + break + } + } + } + + return sb.String() +} diff --git a/test/loadtest/internal/cmd/root.go b/test/loadtest/internal/cmd/root.go new file mode 100644 index 00000000..46e9be55 --- /dev/null +++ b/test/loadtest/internal/cmd/root.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +const ( + // DefaultClusterName is the default kind cluster name. + DefaultClusterName = "reloader-loadtest" + // TestNamespace is the namespace used for test resources. + TestNamespace = "reloader-test" +) + +// OutputFormat defines the output format for reports. +type OutputFormat string + +const ( + OutputFormatText OutputFormat = "text" + OutputFormatJSON OutputFormat = "json" + OutputFormatMarkdown OutputFormat = "markdown" +) + +// rootCmd is the base command. +var rootCmd = &cobra.Command{ + Use: "loadtest", + Short: "Reloader Load Test CLI", + Long: `A CLI tool for running A/B comparison load tests on Reloader.`, +} + +func init() { + rootCmd.AddCommand(runCmd) + rootCmd.AddCommand(reportCmd) + rootCmd.AddCommand(summaryCmd) +} + +// Execute runs the root command. +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/test/loadtest/internal/cmd/run.go b/test/loadtest/internal/cmd/run.go new file mode 100644 index 00000000..eb45a07e --- /dev/null +++ b/test/loadtest/internal/cmd/run.go @@ -0,0 +1,648 @@ +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/spf13/cobra" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + + "github.com/stakater/Reloader/test/loadtest/internal/cluster" + "github.com/stakater/Reloader/test/loadtest/internal/prometheus" + "github.com/stakater/Reloader/test/loadtest/internal/reloader" + "github.com/stakater/Reloader/test/loadtest/internal/scenarios" +) + +// RunConfig holds CLI configuration for the run command. +type RunConfig struct { + OldImage string + NewImage string + Scenario string + Duration int + SkipCluster bool + SkipImageLoad bool + ClusterName string + ResultsDir string + ManifestsDir string + Parallelism int +} + +// workerContext holds all resources for a single worker (cluster + prometheus). +type workerContext struct { + id int + clusterMgr *cluster.Manager + promMgr *prometheus.Manager + kubeClient kubernetes.Interface + kubeContext string + runtime string +} + +var runCfg RunConfig + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Run A/B comparison tests", + Long: `Run load tests comparing old and new versions of Reloader. + +Examples: + # Compare two images + loadtest run --old-image=stakater/reloader:v1.0.0 --new-image=stakater/reloader:v1.1.0 + + # Run specific scenario + loadtest run --old-image=stakater/reloader:v1.0.0 --new-image=localhost/reloader:dev --scenario=S2 + + # Test single image (no comparison) + loadtest run --new-image=localhost/reloader:test + + # Run all scenarios in parallel on 4 clusters + loadtest run --new-image=localhost/reloader:test --parallelism=4`, + Run: func(cmd *cobra.Command, args []string) { + runCommand() + }, +} + +func init() { + runCmd.Flags().StringVar(&runCfg.OldImage, "old-image", "", "Container image for \"old\" version (required for comparison)") + runCmd.Flags().StringVar(&runCfg.NewImage, "new-image", "", "Container image for \"new\" version (required for comparison)") + runCmd.Flags().StringVar(&runCfg.Scenario, "scenario", "all", "Test scenario: S1-S13 or \"all\"") + runCmd.Flags().IntVar(&runCfg.Duration, "duration", 60, "Test duration in seconds") + runCmd.Flags().IntVar(&runCfg.Parallelism, "parallelism", 1, "Run N scenarios in parallel on N clusters") + runCmd.Flags().BoolVar(&runCfg.SkipCluster, "skip-cluster", false, "Skip kind cluster creation (use existing)") + runCmd.Flags().BoolVar(&runCfg.SkipImageLoad, "skip-image-load", false, "Skip loading images into kind (use when images already loaded)") + runCmd.Flags().StringVar(&runCfg.ClusterName, "cluster-name", DefaultClusterName, "Kind cluster name") + runCmd.Flags().StringVar(&runCfg.ResultsDir, "results-dir", "./results", "Directory for results") + runCmd.Flags().StringVar(&runCfg.ManifestsDir, "manifests-dir", "", "Directory containing manifests (auto-detected if not set)") +} + +func runCommand() { + if runCfg.ManifestsDir == "" { + execPath, _ := os.Executable() + execDir := filepath.Dir(execPath) + runCfg.ManifestsDir = filepath.Join(execDir, "..", "..", "manifests") + if _, err := os.Stat(runCfg.ManifestsDir); os.IsNotExist(err) { + runCfg.ManifestsDir = "./manifests" + } + } + + if runCfg.Parallelism < 1 { + runCfg.Parallelism = 1 + } + + if runCfg.OldImage == "" && runCfg.NewImage == "" { + log.Fatal("At least one of --old-image or --new-image is required") + } + + runOld := runCfg.OldImage != "" + runNew := runCfg.NewImage != "" + runBoth := runOld && runNew + + log.Printf("Configuration:") + log.Printf(" Scenario: %s", runCfg.Scenario) + log.Printf(" Duration: %ds", runCfg.Duration) + log.Printf(" Parallelism: %d", runCfg.Parallelism) + if runCfg.OldImage != "" { + log.Printf(" Old image: %s", runCfg.OldImage) + } + if runCfg.NewImage != "" { + log.Printf(" New image: %s", runCfg.NewImage) + } + + runtime, err := cluster.DetectContainerRuntime() + if err != nil { + log.Fatalf("Failed to detect container runtime: %v", err) + } + log.Printf(" Container runtime: %s", runtime) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + log.Println("Received shutdown signal...") + cancel() + }() + + var scenariosToRun []string + if runCfg.Scenario == "all" { + scenariosToRun = []string{"S1", "S2", "S3", "S4", "S5", "S6", "S7", "S8", "S9", "S10", "S11", "S12", "S13"} + } else { + // Split comma-separated scenarios (e.g., "S1,S4,S6") + for _, s := range strings.Split(runCfg.Scenario, ",") { + if trimmed := strings.TrimSpace(s); trimmed != "" { + scenariosToRun = append(scenariosToRun, trimmed) + } + } + } + + if runCfg.SkipCluster && runCfg.Parallelism > 1 { + log.Fatal("--skip-cluster is not supported with --parallelism > 1") + } + + if runCfg.Parallelism > 1 { + runParallel(ctx, runCfg, scenariosToRun, runtime, runOld, runNew, runBoth) + return + } + + runSequential(ctx, runCfg, scenariosToRun, runtime, runOld, runNew, runBoth) +} + +func runSequential(ctx context.Context, cfg RunConfig, scenariosToRun []string, runtime string, runOld, runNew, runBoth bool) { + clusterMgr := cluster.NewManager(cluster.Config{ + Name: cfg.ClusterName, + ContainerRuntime: runtime, + }) + + if cfg.SkipCluster { + log.Printf("Skipping cluster creation (using existing cluster: %s)", cfg.ClusterName) + if !clusterMgr.Exists() { + log.Fatalf("Cluster %s does not exist. Remove --skip-cluster to create it.", cfg.ClusterName) + } + } else { + log.Println("Creating kind cluster...") + if err := clusterMgr.Create(ctx); err != nil { + log.Fatalf("Failed to create cluster: %v", err) + } + } + + promManifest := filepath.Join(cfg.ManifestsDir, "prometheus.yaml") + promMgr := prometheus.NewManager(promManifest) + + log.Println("Installing Prometheus...") + if err := promMgr.Deploy(ctx); err != nil { + log.Fatalf("Failed to deploy Prometheus: %v", err) + } + + if err := promMgr.StartPortForward(ctx); err != nil { + log.Fatalf("Failed to start Prometheus port-forward: %v", err) + } + defer promMgr.StopPortForward() + + if cfg.SkipImageLoad { + log.Println("Skipping image loading (--skip-image-load)") + } else { + log.Println("Loading images into kind cluster...") + if runOld { + log.Printf("Loading old image: %s", cfg.OldImage) + if err := clusterMgr.LoadImage(ctx, cfg.OldImage); err != nil { + log.Fatalf("Failed to load old image: %v", err) + } + } + if runNew { + log.Printf("Loading new image: %s", cfg.NewImage) + if err := clusterMgr.LoadImage(ctx, cfg.NewImage); err != nil { + log.Fatalf("Failed to load new image: %v", err) + } + } + + log.Println("Pre-loading test images...") + testImage := "gcr.io/google-containers/busybox:1.27" + clusterMgr.LoadImage(ctx, testImage) + } + + kubeClient, err := getKubeClient("") + if err != nil { + log.Fatalf("Failed to create kubernetes client: %v", err) + } + + for _, scenarioID := range scenariosToRun { + log.Printf("========================================") + log.Printf("=== Starting scenario %s ===", scenarioID) + log.Printf("========================================") + + cleanupTestNamespaces(ctx, "") + reloader.CleanupByVersion(ctx, "old", "") + reloader.CleanupByVersion(ctx, "new", "") + + if err := promMgr.Reset(ctx); err != nil { + log.Printf("Warning: failed to reset Prometheus: %v", err) + } + + createTestNamespace(ctx, "") + + if runOld { + oldMgr := reloader.NewManager(reloader.Config{ + Version: "old", + Image: cfg.OldImage, + }) + + if err := oldMgr.Deploy(ctx); err != nil { + log.Printf("Failed to deploy old Reloader: %v", err) + continue + } + + if err := promMgr.WaitForTarget(ctx, oldMgr.Job(), 60*time.Second); err != nil { + log.Printf("Warning: %v", err) + log.Println("Proceeding anyway, but metrics may be incomplete") + } + + runScenario(ctx, kubeClient, scenarioID, "old", cfg.OldImage, cfg.Duration, cfg.ResultsDir) + collectMetrics(ctx, promMgr, oldMgr.Job(), scenarioID, "old", cfg.ResultsDir) + collectLogs(ctx, oldMgr, scenarioID, "old", cfg.ResultsDir) + + if runBoth { + cleanupTestNamespaces(ctx, "") + oldMgr.Cleanup(ctx) + promMgr.Reset(ctx) + createTestNamespace(ctx, "") + } + } + + if runNew { + newMgr := reloader.NewManager(reloader.Config{ + Version: "new", + Image: cfg.NewImage, + }) + + if err := newMgr.Deploy(ctx); err != nil { + log.Printf("Failed to deploy new Reloader: %v", err) + continue + } + + if err := promMgr.WaitForTarget(ctx, newMgr.Job(), 60*time.Second); err != nil { + log.Printf("Warning: %v", err) + log.Println("Proceeding anyway, but metrics may be incomplete") + } + + runScenario(ctx, kubeClient, scenarioID, "new", cfg.NewImage, cfg.Duration, cfg.ResultsDir) + collectMetrics(ctx, promMgr, newMgr.Job(), scenarioID, "new", cfg.ResultsDir) + collectLogs(ctx, newMgr, scenarioID, "new", cfg.ResultsDir) + } + + generateReport(scenarioID, cfg.ResultsDir, runBoth) + log.Printf("=== Scenario %s complete ===", scenarioID) + } + + log.Println("Load test complete!") + log.Printf("Results available in: %s", cfg.ResultsDir) +} + +func runParallel(ctx context.Context, cfg RunConfig, scenariosToRun []string, runtime string, runOld, runNew, runBoth bool) { + numWorkers := cfg.Parallelism + if numWorkers > len(scenariosToRun) { + numWorkers = len(scenariosToRun) + log.Printf("Reducing parallelism to %d (number of scenarios)", numWorkers) + } + + log.Printf("Starting parallel execution with %d workers", numWorkers) + + workers := make([]*workerContext, numWorkers) + var setupWg sync.WaitGroup + setupErrors := make(chan error, numWorkers) + + log.Println("Setting up worker clusters...") + for i := range numWorkers { + setupWg.Add(1) + go func(workerID int) { + defer setupWg.Done() + worker, err := setupWorker(ctx, cfg, workerID, runtime, runOld, runNew) + if err != nil { + setupErrors <- fmt.Errorf("worker %d setup failed: %w", workerID, err) + return + } + workers[workerID] = worker + }(i) + } + + setupWg.Wait() + close(setupErrors) + + for err := range setupErrors { + log.Printf("Error: %v", err) + } + + readyWorkers := 0 + for _, w := range workers { + if w != nil { + readyWorkers++ + } + } + if readyWorkers == 0 { + log.Fatal("No workers ready, aborting") + } + if readyWorkers < numWorkers { + log.Printf("Warning: only %d/%d workers ready", readyWorkers, numWorkers) + } + + defer func() { + log.Println("Cleaning up worker clusters...") + for _, w := range workers { + if w != nil { + w.promMgr.StopPortForward() + } + } + }() + + scenarioCh := make(chan string, len(scenariosToRun)) + for _, s := range scenariosToRun { + scenarioCh <- s + } + close(scenarioCh) + + var resultsMu sync.Mutex + completedScenarios := make([]string, 0, len(scenariosToRun)) + + var wg sync.WaitGroup + for _, worker := range workers { + if worker == nil { + continue + } + wg.Add(1) + go func(w *workerContext) { + defer wg.Done() + for scenarioID := range scenarioCh { + select { + case <-ctx.Done(): + return + default: + } + + log.Printf("[Worker %d] Starting scenario %s", w.id, scenarioID) + + cleanupTestNamespaces(ctx, w.kubeContext) + reloader.CleanupByVersion(ctx, "old", w.kubeContext) + reloader.CleanupByVersion(ctx, "new", w.kubeContext) + + if err := w.promMgr.Reset(ctx); err != nil { + log.Printf("[Worker %d] Warning: failed to reset Prometheus: %v", w.id, err) + } + + createTestNamespace(ctx, w.kubeContext) + + if runOld { + runVersionOnWorker(ctx, w, cfg, scenarioID, "old", cfg.OldImage, runBoth) + } + + if runNew { + runVersionOnWorker(ctx, w, cfg, scenarioID, "new", cfg.NewImage, false) + } + + generateReport(scenarioID, cfg.ResultsDir, runBoth) + + resultsMu.Lock() + completedScenarios = append(completedScenarios, scenarioID) + resultsMu.Unlock() + + log.Printf("[Worker %d] Scenario %s complete", w.id, scenarioID) + } + }(worker) + } + + wg.Wait() + + log.Println("Load test complete!") + log.Printf("Completed %d/%d scenarios", len(completedScenarios), len(scenariosToRun)) + log.Printf("Results available in: %s", cfg.ResultsDir) +} + +func setupWorker(ctx context.Context, cfg RunConfig, workerID int, runtime string, runOld, runNew bool) (*workerContext, error) { + workerName := fmt.Sprintf("%s-%d", DefaultClusterName, workerID) + promPort := 9091 + workerID + + log.Printf("[Worker %d] Creating cluster %s (ports %d/%d)...", workerID, workerName, 8080+workerID, 8443+workerID) + + clusterMgr := cluster.NewManager(cluster.Config{ + Name: workerName, + ContainerRuntime: runtime, + PortOffset: workerID, + }) + + if err := clusterMgr.Create(ctx); err != nil { + return nil, fmt.Errorf("creating cluster: %w", err) + } + + kubeContext := clusterMgr.Context() + + promManifest := filepath.Join(cfg.ManifestsDir, "prometheus.yaml") + promMgr := prometheus.NewManagerWithPort(promManifest, promPort, kubeContext) + + log.Printf("[Worker %d] Installing Prometheus (port %d)...", workerID, promPort) + if err := promMgr.Deploy(ctx); err != nil { + return nil, fmt.Errorf("deploying prometheus: %w", err) + } + + if err := promMgr.StartPortForward(ctx); err != nil { + return nil, fmt.Errorf("starting prometheus port-forward: %w", err) + } + + if cfg.SkipImageLoad { + log.Printf("[Worker %d] Skipping image loading (--skip-image-load)", workerID) + } else { + log.Printf("[Worker %d] Loading images...", workerID) + if runOld { + if err := clusterMgr.LoadImage(ctx, cfg.OldImage); err != nil { + log.Printf("[Worker %d] Warning: failed to load old image: %v", workerID, err) + } + } + if runNew { + if err := clusterMgr.LoadImage(ctx, cfg.NewImage); err != nil { + log.Printf("[Worker %d] Warning: failed to load new image: %v", workerID, err) + } + } + + testImage := "gcr.io/google-containers/busybox:1.27" + clusterMgr.LoadImage(ctx, testImage) + } + + kubeClient, err := getKubeClient(kubeContext) + if err != nil { + return nil, fmt.Errorf("creating kubernetes client: %w", err) + } + + log.Printf("[Worker %d] Ready", workerID) + return &workerContext{ + id: workerID, + clusterMgr: clusterMgr, + promMgr: promMgr, + kubeClient: kubeClient, + kubeContext: kubeContext, + runtime: runtime, + }, nil +} + +func runVersionOnWorker(ctx context.Context, w *workerContext, cfg RunConfig, scenarioID, version, image string, cleanupAfter bool) { + mgr := reloader.NewManager(reloader.Config{ + Version: version, + Image: image, + }) + mgr.SetKubeContext(w.kubeContext) + + if err := mgr.Deploy(ctx); err != nil { + log.Printf("[Worker %d] Failed to deploy %s Reloader: %v", w.id, version, err) + return + } + + if err := w.promMgr.WaitForTarget(ctx, mgr.Job(), 60*time.Second); err != nil { + log.Printf("[Worker %d] Warning: %v", w.id, err) + log.Printf("[Worker %d] Proceeding anyway, but metrics may be incomplete", w.id) + } + + runScenario(ctx, w.kubeClient, scenarioID, version, image, cfg.Duration, cfg.ResultsDir) + collectMetrics(ctx, w.promMgr, mgr.Job(), scenarioID, version, cfg.ResultsDir) + collectLogs(ctx, mgr, scenarioID, version, cfg.ResultsDir) + + if cleanupAfter { + cleanupTestNamespaces(ctx, w.kubeContext) + mgr.Cleanup(ctx) + w.promMgr.Reset(ctx) + createTestNamespace(ctx, w.kubeContext) + } +} + +func runScenario(ctx context.Context, client kubernetes.Interface, scenarioID, version, image string, duration int, resultsDir string) { + runner, ok := scenarios.Registry[scenarioID] + if !ok { + log.Printf("Unknown scenario: %s", scenarioID) + return + } + + if s6, ok := runner.(*scenarios.ControllerRestartScenario); ok { + s6.ReloaderVersion = version + } + + if s11, ok := runner.(*scenarios.AnnotationStrategyScenario); ok { + s11.Image = image + } + + log.Printf("Running scenario %s (%s): %s", scenarioID, version, runner.Description()) + + if ctx.Err() != nil { + log.Printf("WARNING: Parent context already done: %v", ctx.Err()) + } + + timeout := time.Duration(duration)*time.Second + 5*time.Minute + log.Printf("Creating scenario context with timeout: %v (duration=%ds)", timeout, duration) + + scenarioCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + expected, err := runner.Run(scenarioCtx, client, TestNamespace, time.Duration(duration)*time.Second) + if err != nil { + log.Printf("Scenario %s failed: %v", scenarioID, err) + } + + scenarios.WriteExpectedMetrics(scenarioID, resultsDir, expected) +} + +func collectMetrics(ctx context.Context, promMgr *prometheus.Manager, job, scenarioID, version, resultsDir string) { + log.Printf("Waiting 5s for Reloader to finish processing events...") + time.Sleep(5 * time.Second) + + log.Printf("Waiting 8s for Prometheus to scrape final metrics...") + time.Sleep(8 * time.Second) + + log.Printf("Collecting metrics for %s...", version) + outputDir := filepath.Join(resultsDir, scenarioID, version) + if err := promMgr.CollectMetrics(ctx, job, outputDir, scenarioID); err != nil { + log.Printf("Failed to collect metrics: %v", err) + } +} + +func collectLogs(ctx context.Context, mgr *reloader.Manager, scenarioID, version, resultsDir string) { + log.Printf("Collecting logs for %s...", version) + logPath := filepath.Join(resultsDir, scenarioID, version, "reloader.log") + if err := mgr.CollectLogs(ctx, logPath); err != nil { + log.Printf("Failed to collect logs: %v", err) + } +} + +func generateReport(scenarioID, resultsDir string, isComparison bool) { + if isComparison { + log.Println("Generating comparison report...") + } else { + log.Println("Generating single-version report...") + } + + reportPath := filepath.Join(resultsDir, scenarioID, "report.txt") + + cmd := exec.Command(os.Args[0], "report", + fmt.Sprintf("--scenario=%s", scenarioID), + fmt.Sprintf("--results-dir=%s", resultsDir), + fmt.Sprintf("--output=%s", reportPath)) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Run() + + if data, err := os.ReadFile(reportPath); err == nil { + fmt.Println(string(data)) + } + + log.Printf("Report saved to: %s", reportPath) +} + +func getKubeClient(kubeContext string) (kubernetes.Interface, error) { + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + home, _ := os.UserHomeDir() + kubeconfig = filepath.Join(home, ".kube", "config") + } + + loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig} + configOverrides := &clientcmd.ConfigOverrides{} + if kubeContext != "" { + configOverrides.CurrentContext = kubeContext + } + + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + config, err := kubeConfig.ClientConfig() + if err != nil { + return nil, err + } + + return kubernetes.NewForConfig(config) +} + +func createTestNamespace(ctx context.Context, kubeContext string) { + args := []string{"create", "namespace", TestNamespace, "--dry-run=client", "-o", "yaml"} + if kubeContext != "" { + args = append([]string{"--context", kubeContext}, args...) + } + cmd := exec.CommandContext(ctx, "kubectl", args...) + out, _ := cmd.Output() + + applyArgs := []string{"apply", "-f", "-"} + if kubeContext != "" { + applyArgs = append([]string{"--context", kubeContext}, applyArgs...) + } + applyCmd := exec.CommandContext(ctx, "kubectl", applyArgs...) + applyCmd.Stdin = strings.NewReader(string(out)) + applyCmd.Run() +} + +func cleanupTestNamespaces(ctx context.Context, kubeContext string) { + log.Println("Cleaning up test resources...") + + namespaces := []string{TestNamespace} + for i := range 10 { + namespaces = append(namespaces, fmt.Sprintf("%s-%d", TestNamespace, i)) + } + + for _, ns := range namespaces { + args := []string{"delete", "namespace", ns, "--wait=false", "--ignore-not-found"} + if kubeContext != "" { + args = append([]string{"--context", kubeContext}, args...) + } + exec.CommandContext(ctx, "kubectl", args...).Run() + } + + time.Sleep(2 * time.Second) + + for _, ns := range namespaces { + args := []string{"delete", "pods", "--all", "-n", ns, "--grace-period=0", "--force"} + if kubeContext != "" { + args = append([]string{"--context", kubeContext}, args...) + } + exec.CommandContext(ctx, "kubectl", args...).Run() + } +} diff --git a/test/loadtest/internal/cmd/summary.go b/test/loadtest/internal/cmd/summary.go new file mode 100644 index 00000000..bda40fb9 --- /dev/null +++ b/test/loadtest/internal/cmd/summary.go @@ -0,0 +1,251 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "log" + "os" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" +) + +var ( + summaryResultsDir string + summaryOutputFile string + summaryFormat string + summaryTestType string +) + +var summaryCmd = &cobra.Command{ + Use: "summary", + Short: "Generate summary across all scenarios (for CI)", + Long: `Generate an aggregated summary report across all test scenarios. + +Examples: + # Generate markdown summary for CI + loadtest summary --results-dir=./results --format=markdown`, + Run: func(cmd *cobra.Command, args []string) { + summaryCommand() + }, +} + +func init() { + summaryCmd.Flags().StringVar(&summaryResultsDir, "results-dir", "./results", "Directory containing results") + summaryCmd.Flags().StringVar(&summaryOutputFile, "output", "", "Output file (default: stdout)") + summaryCmd.Flags().StringVar(&summaryFormat, "format", "markdown", "Output format: text, json, markdown") + summaryCmd.Flags().StringVar(&summaryTestType, "test-type", "full", "Test type label: quick, full") +} + +// SummaryReport aggregates results from multiple scenarios. +type SummaryReport struct { + Timestamp time.Time `json:"timestamp"` + TestType string `json:"test_type"` + PassCount int `json:"pass_count"` + FailCount int `json:"fail_count"` + TotalCount int `json:"total_count"` + Scenarios []ScenarioSummary `json:"scenarios"` +} + +// ScenarioSummary provides a brief summary of a single scenario. +type ScenarioSummary struct { + ID string `json:"id"` + Status string `json:"status"` + Description string `json:"description"` + ActionTotal float64 `json:"action_total"` + ActionExp float64 `json:"action_expected"` + ErrorsTotal float64 `json:"errors_total"` +} + +func summaryCommand() { + summary, err := generateSummaryReport(summaryResultsDir, summaryTestType) + if err != nil { + log.Fatalf("Failed to generate summary: %v", err) + } + + var output string + switch OutputFormat(summaryFormat) { + case OutputFormatJSON: + output = renderSummaryJSON(summary) + case OutputFormatText: + output = renderSummaryText(summary) + default: + output = renderSummaryMarkdown(summary) + } + + if summaryOutputFile != "" { + if err := os.WriteFile(summaryOutputFile, []byte(output), 0644); err != nil { + log.Fatalf("Failed to write output file: %v", err) + } + log.Printf("Summary written to %s", summaryOutputFile) + } else { + fmt.Print(output) + } + + if summary.FailCount > 0 { + os.Exit(1) + } +} + +func generateSummaryReport(resultsDir, testType string) (*SummaryReport, error) { + summary := &SummaryReport{ + Timestamp: time.Now(), + TestType: testType, + } + + entries, err := os.ReadDir(resultsDir) + if err != nil { + return nil, fmt.Errorf("failed to read results directory: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "S") { + continue + } + + scenarioID := entry.Name() + report, err := generateScenarioReport(scenarioID, resultsDir) + if err != nil { + log.Printf("Warning: failed to load scenario %s: %v", scenarioID, err) + continue + } + + scenarioSummary := ScenarioSummary{ + ID: scenarioID, + Status: report.OverallStatus, + Description: report.TestDescription, + } + + for _, c := range report.Comparisons { + switch c.Name { + case "action_total": + scenarioSummary.ActionTotal = c.NewValue + scenarioSummary.ActionExp = c.Expected + case "errors_total": + scenarioSummary.ErrorsTotal = c.NewValue + } + } + + summary.Scenarios = append(summary.Scenarios, scenarioSummary) + summary.TotalCount++ + if report.OverallStatus == "PASS" { + summary.PassCount++ + } else { + summary.FailCount++ + } + } + + sort.Slice(summary.Scenarios, func(i, j int) bool { + return naturalSort(summary.Scenarios[i].ID, summary.Scenarios[j].ID) + }) + + return summary, nil +} + +func naturalSort(a, b string) bool { + var aNum, bNum int + fmt.Sscanf(a, "S%d", &aNum) + fmt.Sscanf(b, "S%d", &bNum) + return aNum < bNum +} + +func renderSummaryJSON(summary *SummaryReport) string { + data, err := json.MarshalIndent(summary, "", " ") + if err != nil { + return fmt.Sprintf(`{"error": "%s"}`, err.Error()) + } + return string(data) +} + +func renderSummaryText(summary *SummaryReport) string { + var sb strings.Builder + + sb.WriteString("================================================================================\n") + sb.WriteString(" LOAD TEST SUMMARY\n") + sb.WriteString("================================================================================\n\n") + + passRate := 0 + if summary.TotalCount > 0 { + passRate = summary.PassCount * 100 / summary.TotalCount + } + + fmt.Fprintf(&sb, "Test Type: %s\n", summary.TestType) + fmt.Fprintf(&sb, "Results: %d/%d passed (%d%%)\n\n", summary.PassCount, summary.TotalCount, passRate) + + fmt.Fprintf(&sb, "%-6s %-8s %-45s %10s %8s\n", "ID", "Status", "Description", "Actions", "Errors") + fmt.Fprintf(&sb, "%-6s %-8s %-45s %10s %8s\n", "------", "--------", strings.Repeat("-", 45), "----------", "--------") + + for _, s := range summary.Scenarios { + desc := s.Description + if len(desc) > 45 { + desc = desc[:42] + "..." + } + actions := fmt.Sprintf("%.0f", s.ActionTotal) + if s.ActionExp > 0 { + actions = fmt.Sprintf("%.0f/%.0f", s.ActionTotal, s.ActionExp) + } + fmt.Fprintf(&sb, "%-6s %-8s %-45s %10s %8.0f\n", s.ID, s.Status, desc, actions, s.ErrorsTotal) + } + + sb.WriteString("\n================================================================================\n") + return sb.String() +} + +func renderSummaryMarkdown(summary *SummaryReport) string { + var sb strings.Builder + + emoji := "✅" + title := "ALL TESTS PASSED" + if summary.FailCount > 0 { + emoji = "❌" + title = fmt.Sprintf("%d TEST(S) FAILED", summary.FailCount) + } else if summary.TotalCount == 0 { + emoji = "⚠️" + title = "NO RESULTS" + } + + sb.WriteString(fmt.Sprintf("## %s Load Test Results: %s\n\n", emoji, title)) + + if summary.TestType == "quick" { + sb.WriteString("> 🚀 **Quick Test** (S1, S4, S6) — Use `/loadtest` for full suite\n\n") + } + + passRate := 0 + if summary.TotalCount > 0 { + passRate = summary.PassCount * 100 / summary.TotalCount + } + sb.WriteString(fmt.Sprintf("**%d/%d passed** (%d%%)\n\n", summary.PassCount, summary.TotalCount, passRate)) + + sb.WriteString("| | Scenario | Description | Actions | Errors |\n") + sb.WriteString("|:-:|:--------:|-------------|:-------:|:------:|\n") + + for _, s := range summary.Scenarios { + icon := "✅" + if s.Status != "PASS" { + icon = "❌" + } + + desc := s.Description + if len(desc) > 45 { + desc = desc[:42] + "..." + } + + actions := fmt.Sprintf("%.0f", s.ActionTotal) + if s.ActionExp > 0 { + actions = fmt.Sprintf("%.0f/%.0f", s.ActionTotal, s.ActionExp) + } + + errors := fmt.Sprintf("%.0f", s.ErrorsTotal) + if s.ErrorsTotal > 0 { + errors = fmt.Sprintf("⚠️ %.0f", s.ErrorsTotal) + } + + sb.WriteString(fmt.Sprintf("| %s | **%s** | %s | %s | %s |\n", icon, s.ID, desc, actions, errors)) + } + + sb.WriteString("\n📦 **[Download detailed results](../artifacts)**\n") + + return sb.String() +} diff --git a/test/loadtest/internal/prometheus/prometheus.go b/test/loadtest/internal/prometheus/prometheus.go new file mode 100644 index 00000000..b9bf7555 --- /dev/null +++ b/test/loadtest/internal/prometheus/prometheus.go @@ -0,0 +1,429 @@ +// Package prometheus provides Prometheus deployment and querying functionality. +package prometheus + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// Manager handles Prometheus operations. +type Manager struct { + manifestPath string + portForward *exec.Cmd + localPort int + kubeContext string +} + +// NewManager creates a new Prometheus manager. +func NewManager(manifestPath string) *Manager { + return &Manager{ + manifestPath: manifestPath, + localPort: 9091, + } +} + +// NewManagerWithPort creates a Prometheus manager with a custom port. +func NewManagerWithPort(manifestPath string, port int, kubeContext string) *Manager { + return &Manager{ + manifestPath: manifestPath, + localPort: port, + kubeContext: kubeContext, + } +} + +// kubectl returns kubectl args with optional context +func (m *Manager) kubectl(args ...string) []string { + if m.kubeContext != "" { + return append([]string{"--context", m.kubeContext}, args...) + } + return args +} + +// Deploy deploys Prometheus to the cluster. +func (m *Manager) Deploy(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "kubectl", m.kubectl("create", "namespace", "monitoring", "--dry-run=client", "-o", "yaml")...) + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("generating namespace yaml: %w", err) + } + + applyCmd := exec.CommandContext(ctx, "kubectl", m.kubectl("apply", "-f", "-")...) + applyCmd.Stdin = strings.NewReader(string(out)) + if err := applyCmd.Run(); err != nil { + return fmt.Errorf("applying namespace: %w", err) + } + + applyCmd = exec.CommandContext(ctx, "kubectl", m.kubectl("apply", "-f", m.manifestPath)...) + applyCmd.Stdout = os.Stdout + applyCmd.Stderr = os.Stderr + if err := applyCmd.Run(); err != nil { + return fmt.Errorf("applying prometheus manifest: %w", err) + } + + fmt.Println("Waiting for Prometheus to be ready...") + waitCmd := exec.CommandContext(ctx, "kubectl", m.kubectl("wait", "--for=condition=ready", "pod", + "-l", "app=prometheus", "-n", "monitoring", "--timeout=120s")...) + waitCmd.Stdout = os.Stdout + waitCmd.Stderr = os.Stderr + if err := waitCmd.Run(); err != nil { + return fmt.Errorf("waiting for prometheus: %w", err) + } + + return nil +} + +// StartPortForward starts port-forwarding to Prometheus. +func (m *Manager) StartPortForward(ctx context.Context) error { + m.StopPortForward() + + m.portForward = exec.CommandContext(ctx, "kubectl", m.kubectl("port-forward", + "-n", "monitoring", "svc/prometheus", fmt.Sprintf("%d:9090", m.localPort))...) + + if err := m.portForward.Start(); err != nil { + return fmt.Errorf("starting port-forward: %w", err) + } + + for i := 0; i < 30; i++ { + time.Sleep(time.Second) + if m.isAccessible() { + fmt.Printf("Prometheus accessible at http://localhost:%d\n", m.localPort) + return nil + } + } + + return fmt.Errorf("prometheus port-forward not ready after 30s") +} + +// StopPortForward stops the port-forward process. +func (m *Manager) StopPortForward() { + if m.portForward != nil && m.portForward.Process != nil { + m.portForward.Process.Kill() + m.portForward = nil + } + exec.Command("pkill", "-f", fmt.Sprintf("kubectl port-forward.*prometheus.*%d", m.localPort)).Run() +} + +// Reset restarts Prometheus to clear all metrics. +func (m *Manager) Reset(ctx context.Context) error { + m.StopPortForward() + + cmd := exec.CommandContext(ctx, "kubectl", m.kubectl("delete", "pod", "-n", "monitoring", + "-l", "app=prometheus", "--grace-period=0", "--force")...) + cmd.Run() + + fmt.Println("Waiting for Prometheus to restart...") + waitCmd := exec.CommandContext(ctx, "kubectl", m.kubectl("wait", "--for=condition=ready", "pod", + "-l", "app=prometheus", "-n", "monitoring", "--timeout=120s")...) + if err := waitCmd.Run(); err != nil { + return fmt.Errorf("waiting for prometheus restart: %w", err) + } + + if err := m.StartPortForward(ctx); err != nil { + return err + } + + fmt.Println("Waiting 5s for Prometheus to initialize scraping...") + time.Sleep(5 * time.Second) + + return nil +} + +func (m *Manager) isAccessible() bool { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", m.localPort), 2*time.Second) + if err != nil { + return false + } + conn.Close() + + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/status/config", m.localPort)) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == 200 +} + +// URL returns the local Prometheus URL. +func (m *Manager) URL() string { + return fmt.Sprintf("http://localhost:%d", m.localPort) +} + +// WaitForTarget waits for a specific job to be scraped by Prometheus. +func (m *Manager) WaitForTarget(ctx context.Context, job string, timeout time.Duration) error { + fmt.Printf("Waiting for Prometheus to discover and scrape job '%s'...\n", job) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if m.isTargetHealthy(job) { + fmt.Printf("Prometheus is scraping job '%s'\n", job) + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } + + m.printTargetStatus(job) + return fmt.Errorf("timeout waiting for Prometheus to scrape job '%s'", job) +} + +// isTargetHealthy checks if a job has at least one healthy target. +func (m *Manager) isTargetHealthy(job string) bool { + resp, err := http.Get(fmt.Sprintf("%s/api/v1/targets", m.URL())) + if err != nil { + return false + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return false + } + + var result struct { + Status string `json:"status"` + Data struct { + ActiveTargets []struct { + Labels map[string]string `json:"labels"` + Health string `json:"health"` + } `json:"activeTargets"` + } `json:"data"` + } + + if err := json.Unmarshal(body, &result); err != nil { + return false + } + + for _, target := range result.Data.ActiveTargets { + if target.Labels["job"] == job && target.Health == "up" { + return true + } + } + return false +} + +// printTargetStatus prints debug info about targets. +func (m *Manager) printTargetStatus(job string) { + resp, err := http.Get(fmt.Sprintf("%s/api/v1/targets", m.URL())) + if err != nil { + fmt.Printf("Failed to get targets: %v\n", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + var result struct { + Data struct { + ActiveTargets []struct { + Labels map[string]string `json:"labels"` + Health string `json:"health"` + LastError string `json:"lastError"` + ScrapeURL string `json:"scrapeUrl"` + } `json:"activeTargets"` + } `json:"data"` + } + + if err := json.Unmarshal(body, &result); err != nil { + fmt.Printf("Failed to parse targets: %v\n", err) + return + } + + fmt.Printf("Prometheus targets for job '%s':\n", job) + found := false + for _, target := range result.Data.ActiveTargets { + if target.Labels["job"] == job { + found = true + fmt.Printf(" - %s: health=%s, lastError=%s\n", + target.ScrapeURL, target.Health, target.LastError) + } + } + if !found { + fmt.Printf(" No targets found for job '%s'\n", job) + fmt.Printf(" Available jobs: ") + jobs := make(map[string]bool) + for _, target := range result.Data.ActiveTargets { + jobs[target.Labels["job"]] = true + } + for j := range jobs { + fmt.Printf("%s ", j) + } + fmt.Println() + } +} + +// HasMetrics checks if the specified job has any metrics available. +func (m *Manager) HasMetrics(ctx context.Context, job string) bool { + query := fmt.Sprintf(`up{job="%s"}`, job) + result, err := m.Query(ctx, query) + if err != nil { + return false + } + return len(result.Data.Result) > 0 && result.Data.Result[0].Value[1] == "1" +} + +// QueryResponse represents a Prometheus query response. +type QueryResponse struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []struct { + Metric map[string]string `json:"metric"` + Value []interface{} `json:"value"` + } `json:"result"` + } `json:"data"` +} + +// Query executes a PromQL query and returns the response. +func (m *Manager) Query(ctx context.Context, query string) (*QueryResponse, error) { + u := fmt.Sprintf("%s/api/v1/query?query=%s", m.URL(), url.QueryEscape(query)) + + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("querying prometheus: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var result QueryResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + + return &result, nil +} + +// CollectMetrics collects all metrics for a scenario and writes to output directory. +func (m *Manager) CollectMetrics(ctx context.Context, job, outputDir, scenario string) error { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + + timeRange := "10m" + + // For S6 (restart scenario), use increase() to handle counter resets + useIncrease := scenario == "S6" + + counterMetrics := []string{ + "reloader_reconcile_total", + "reloader_action_total", + "reloader_skipped_total", + "reloader_errors_total", + "reloader_events_received_total", + "reloader_workloads_scanned_total", + "reloader_workloads_matched_total", + "reloader_reload_executed_total", + } + + for _, metric := range counterMetrics { + var query string + if useIncrease { + query = fmt.Sprintf(`sum(increase(%s{job="%s"}[%s])) by (success, reason)`, metric, job, timeRange) + } else { + query = fmt.Sprintf(`sum(%s{job="%s"}) by (success, reason)`, metric, job) + } + + if err := m.queryAndSave(ctx, query, filepath.Join(outputDir, metric+".json")); err != nil { + fmt.Printf("Warning: failed to collect %s: %v\n", metric, err) + } + } + + histogramMetrics := []struct { + name string + prefix string + }{ + {"reloader_reconcile_duration_seconds", "reconcile"}, + {"reloader_action_latency_seconds", "action"}, + } + + for _, hm := range histogramMetrics { + for _, pct := range []int{50, 95, 99} { + quantile := float64(pct) / 100 + query := fmt.Sprintf(`histogram_quantile(%v, sum(rate(%s_bucket{job="%s"}[%s])) by (le))`, + quantile, hm.name, job, timeRange) + outFile := filepath.Join(outputDir, fmt.Sprintf("%s_p%d.json", hm.prefix, pct)) + if err := m.queryAndSave(ctx, query, outFile); err != nil { + fmt.Printf("Warning: failed to collect %s p%d: %v\n", hm.name, pct, err) + } + } + } + + restQueries := map[string]string{ + "rest_client_requests_total.json": fmt.Sprintf(`sum(rest_client_requests_total{job="%s"})`, job), + "rest_client_requests_get.json": fmt.Sprintf(`sum(rest_client_requests_total{job="%s",method="GET"})`, job), + "rest_client_requests_patch.json": fmt.Sprintf(`sum(rest_client_requests_total{job="%s",method="PATCH"})`, job), + "rest_client_requests_put.json": fmt.Sprintf(`sum(rest_client_requests_total{job="%s",method="PUT"})`, job), + "rest_client_requests_errors.json": fmt.Sprintf(`sum(rest_client_requests_total{job="%s",code=~"[45].."}) or vector(0)`, job), + } + + for filename, query := range restQueries { + if err := m.queryAndSave(ctx, query, filepath.Join(outputDir, filename)); err != nil { + fmt.Printf("Warning: failed to collect %s: %v\n", filename, err) + } + } + + resourceQueries := map[string]string{ + "memory_rss_bytes_avg.json": fmt.Sprintf(`avg_over_time(process_resident_memory_bytes{job="%s"}[%s])`, job, timeRange), + "memory_rss_bytes_max.json": fmt.Sprintf(`max_over_time(process_resident_memory_bytes{job="%s"}[%s])`, job, timeRange), + "memory_rss_bytes_cur.json": fmt.Sprintf(`process_resident_memory_bytes{job="%s"}`, job), + + "memory_heap_bytes_avg.json": fmt.Sprintf(`avg_over_time(go_memstats_heap_alloc_bytes{job="%s"}[%s])`, job, timeRange), + "memory_heap_bytes_max.json": fmt.Sprintf(`max_over_time(go_memstats_heap_alloc_bytes{job="%s"}[%s])`, job, timeRange), + + "cpu_usage_cores_avg.json": fmt.Sprintf(`rate(process_cpu_seconds_total{job="%s"}[%s])`, job, timeRange), + "cpu_usage_cores_max.json": fmt.Sprintf(`max_over_time(rate(process_cpu_seconds_total{job="%s"}[1m])[%s:1m])`, job, timeRange), + + "goroutines_avg.json": fmt.Sprintf(`avg_over_time(go_goroutines{job="%s"}[%s])`, job, timeRange), + "goroutines_max.json": fmt.Sprintf(`max_over_time(go_goroutines{job="%s"}[%s])`, job, timeRange), + "goroutines_cur.json": fmt.Sprintf(`go_goroutines{job="%s"}`, job), + + "gc_duration_seconds_p99.json": fmt.Sprintf(`histogram_quantile(0.99, sum(rate(go_gc_duration_seconds_bucket{job="%s"}[%s])) by (le))`, job, timeRange), + + "threads_cur.json": fmt.Sprintf(`go_threads{job="%s"}`, job), + } + + for filename, query := range resourceQueries { + if err := m.queryAndSave(ctx, query, filepath.Join(outputDir, filename)); err != nil { + fmt.Printf("Warning: failed to collect %s: %v\n", filename, err) + } + } + + return nil +} + +func (m *Manager) queryAndSave(ctx context.Context, query, outputPath string) error { + result, err := m.Query(ctx, query) + if err != nil { + emptyResult := `{"status":"success","data":{"resultType":"vector","result":[]}}` + return os.WriteFile(outputPath, []byte(emptyResult), 0644) + } + + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + + return os.WriteFile(outputPath, data, 0644) +} diff --git a/test/loadtest/internal/reloader/reloader.go b/test/loadtest/internal/reloader/reloader.go new file mode 100644 index 00000000..2667cd47 --- /dev/null +++ b/test/loadtest/internal/reloader/reloader.go @@ -0,0 +1,271 @@ +package reloader + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// Config holds configuration for a Reloader deployment. +type Config struct { + Version string + Image string + Namespace string + ReloadStrategy string +} + +// Manager handles Reloader deployment operations. +type Manager struct { + config Config + kubeContext string +} + +// NewManager creates a new Reloader manager. +func NewManager(config Config) *Manager { + return &Manager{ + config: config, + } +} + +// SetKubeContext sets the kubeconfig context to use. +func (m *Manager) SetKubeContext(kubeContext string) { + m.kubeContext = kubeContext +} + +// kubectl returns kubectl command with optional context. +func (m *Manager) kubectl(ctx context.Context, args ...string) *exec.Cmd { + if m.kubeContext != "" { + args = append([]string{"--context", m.kubeContext}, args...) + } + return exec.CommandContext(ctx, "kubectl", args...) +} + +// namespace returns the namespace for this reloader instance. +func (m *Manager) namespace() string { + if m.config.Namespace != "" { + return m.config.Namespace + } + return fmt.Sprintf("reloader-%s", m.config.Version) +} + +// releaseName returns the release name for this instance. +func (m *Manager) releaseName() string { + return fmt.Sprintf("reloader-%s", m.config.Version) +} + +// Job returns the Prometheus job name for this Reloader instance. +func (m *Manager) Job() string { + return fmt.Sprintf("reloader-%s", m.config.Version) +} + +// Deploy deploys Reloader to the cluster using raw manifests. +func (m *Manager) Deploy(ctx context.Context) error { + ns := m.namespace() + name := m.releaseName() + + fmt.Printf("Deploying Reloader (%s) with image %s...\n", m.config.Version, m.config.Image) + + manifest := m.buildManifest(ns, name) + + applyCmd := m.kubectl(ctx, "apply", "-f", "-") + applyCmd.Stdin = strings.NewReader(manifest) + applyCmd.Stdout = os.Stdout + applyCmd.Stderr = os.Stderr + if err := applyCmd.Run(); err != nil { + return fmt.Errorf("applying manifest: %w", err) + } + + fmt.Printf("Waiting for Reloader deployment to be ready...\n") + waitCmd := m.kubectl(ctx, "rollout", "status", "deployment", name, + "-n", ns, + "--timeout=120s") + waitCmd.Stdout = os.Stdout + waitCmd.Stderr = os.Stderr + if err := waitCmd.Run(); err != nil { + return fmt.Errorf("waiting for deployment: %w", err) + } + + time.Sleep(2 * time.Second) + + fmt.Printf("Reloader (%s) deployed successfully\n", m.config.Version) + return nil +} + +// buildManifest creates the raw Kubernetes manifest for Reloader. +func (m *Manager) buildManifest(ns, name string) string { + var args []string + args = append(args, "--log-format=json") + if m.config.ReloadStrategy != "" && m.config.ReloadStrategy != "default" { + args = append(args, fmt.Sprintf("--reload-strategy=%s", m.config.ReloadStrategy)) + } + + argsYAML := "" + if len(args) > 0 { + argsYAML = " args:\n" + for _, arg := range args { + argsYAML += fmt.Sprintf(" - %q\n", arg) + } + } + + return fmt.Sprintf(`--- +apiVersion: v1 +kind: Namespace +metadata: + name: %[1]s +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: %[2]s + namespace: %[1]s +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: %[2]s +rules: +- apiGroups: ["*"] + resources: ["*"] + verbs: ["*"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: %[2]s +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: %[2]s +subjects: +- kind: ServiceAccount + name: %[2]s + namespace: %[1]s +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: %[2]s + namespace: %[1]s + labels: + app: %[2]s + app.kubernetes.io/name: reloader + loadtest-version: %[3]s +spec: + replicas: 1 + selector: + matchLabels: + app: %[2]s + template: + metadata: + labels: + app: %[2]s + app.kubernetes.io/name: reloader + loadtest-version: %[3]s + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "9090" + prometheus.io/path: "/metrics" + spec: + serviceAccountName: %[2]s + securityContext: + runAsNonRoot: true + runAsUser: 65534 + containers: + - name: reloader + image: %[4]s + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 9090 +%[5]s resources: + requests: + cpu: 10m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true +`, ns, name, m.config.Version, m.config.Image, argsYAML) +} + +// Cleanup removes all Reloader resources from the cluster. +func (m *Manager) Cleanup(ctx context.Context) error { + ns := m.namespace() + name := m.releaseName() + + delDeploy := m.kubectl(ctx, "delete", "deployment", name, "-n", ns, "--ignore-not-found") + delDeploy.Run() + + delCRB := m.kubectl(ctx, "delete", "clusterrolebinding", name, "--ignore-not-found") + delCRB.Run() + + delCR := m.kubectl(ctx, "delete", "clusterrole", name, "--ignore-not-found") + delCR.Run() + + delNS := m.kubectl(ctx, "delete", "namespace", ns, "--wait=false", "--ignore-not-found") + if err := delNS.Run(); err != nil { + return fmt.Errorf("deleting namespace: %w", err) + } + + return nil +} + +// CleanupByVersion removes Reloader resources for a specific version without needing a Manager instance. +// This is useful for cleaning up from previous runs before creating a new Manager. +func CleanupByVersion(ctx context.Context, version, kubeContext string) { + ns := fmt.Sprintf("reloader-%s", version) + name := fmt.Sprintf("reloader-%s", version) + + nsArgs := []string{"delete", "namespace", ns, "--wait=false", "--ignore-not-found"} + crArgs := []string{"delete", "clusterrole", name, "--ignore-not-found"} + crbArgs := []string{"delete", "clusterrolebinding", name, "--ignore-not-found"} + + if kubeContext != "" { + nsArgs = append([]string{"--context", kubeContext}, nsArgs...) + crArgs = append([]string{"--context", kubeContext}, crArgs...) + crbArgs = append([]string{"--context", kubeContext}, crbArgs...) + } + + exec.CommandContext(ctx, "kubectl", nsArgs...).Run() + exec.CommandContext(ctx, "kubectl", crArgs...).Run() + exec.CommandContext(ctx, "kubectl", crbArgs...).Run() +} + +// CollectLogs collects logs from the Reloader pod and writes them to the specified file. +func (m *Manager) CollectLogs(ctx context.Context, logPath string) error { + ns := m.namespace() + name := m.releaseName() + + if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil { + return fmt.Errorf("creating log directory: %w", err) + } + + cmd := m.kubectl(ctx, "logs", + "-n", ns, + "-l", fmt.Sprintf("app=%s", name), + "--tail=-1") + + out, err := cmd.Output() + if err != nil { + cmd = m.kubectl(ctx, "logs", + "-n", ns, + "-l", "app.kubernetes.io/name=reloader", + "--tail=-1") + out, err = cmd.Output() + if err != nil { + return fmt.Errorf("collecting logs: %w", err) + } + } + + if err := os.WriteFile(logPath, out, 0644); err != nil { + return fmt.Errorf("writing logs: %w", err) + } + + return nil +} diff --git a/test/loadtest/internal/scenarios/scenarios.go b/test/loadtest/internal/scenarios/scenarios.go new file mode 100644 index 00000000..4909feb1 --- /dev/null +++ b/test/loadtest/internal/scenarios/scenarios.go @@ -0,0 +1,2037 @@ +// Package scenarios contains all load test scenario implementations. +package scenarios + +import ( + "context" + "encoding/json" + "fmt" + "log" + "math/rand" + "os" + "path/filepath" + "sync" + "time" + + "github.com/stakater/Reloader/test/loadtest/internal/reloader" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/kubernetes" +) + +// ExpectedMetrics holds the expected values for metrics verification. +type ExpectedMetrics struct { + ActionTotal int `json:"action_total"` + ReloadExecutedTotal int `json:"reload_executed_total"` + ReconcileTotal int `json:"reconcile_total"` + WorkloadsScannedTotal int `json:"workloads_scanned_total"` + WorkloadsMatchedTotal int `json:"workloads_matched_total"` + SkippedTotal int `json:"skipped_total"` + Description string `json:"description"` +} + +// Runner defines the interface for test scenarios. +type Runner interface { + Name() string + Description() string + Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) +} + +// Registry holds all available test scenarios. +var Registry = map[string]Runner{ + "S1": &BurstUpdateScenario{}, + "S2": &FanOutScenario{}, + "S3": &HighCardinalityScenario{}, + "S4": &NoOpUpdateScenario{}, + "S5": &WorkloadChurnScenario{}, + "S6": &ControllerRestartScenario{}, + "S7": &APIPressureScenario{}, + "S8": &LargeObjectScenario{}, + "S9": &MultiWorkloadTypeScenario{}, + "S10": &SecretsAndMixedScenario{}, + "S11": &AnnotationStrategyScenario{}, + "S12": &PauseResumeScenario{}, + "S13": &ComplexReferencesScenario{}, +} + +// WriteExpectedMetrics writes expected metrics to a JSON file. +func WriteExpectedMetrics(scenario, resultsDir string, expected ExpectedMetrics) error { + if resultsDir == "" { + return nil + } + + dir := filepath.Join(resultsDir, scenario) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating results directory: %w", err) + } + + data, err := json.MarshalIndent(expected, "", " ") + if err != nil { + return fmt.Errorf("marshaling expected metrics: %w", err) + } + + path := filepath.Join(dir, "expected.json") + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing expected metrics: %w", err) + } + + log.Printf("Expected metrics written to %s", path) + return nil +} + +// BurstUpdateScenario - Many ConfigMap/Secret updates in quick succession. +type BurstUpdateScenario struct{} + +func (s *BurstUpdateScenario) Name() string { return "S1" } +func (s *BurstUpdateScenario) Description() string { return "Burst ConfigMap/Secret updates" } + +func (s *BurstUpdateScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S1: Creating base ConfigMaps and Deployments...") + + const numConfigMaps = 10 + const numDeployments = 10 + + setupCtx := context.Background() + + for i := 0; i < numConfigMaps; i++ { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("burst-cm-%d", i), + Namespace: namespace, + }, + Data: map[string]string{"key": "initial-value"}, + } + if _, err := client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create ConfigMap %s: %v", cm.Name, err) + } + } + + for i := 0; i < numDeployments; i++ { + deploy := createDeployment(fmt.Sprintf("burst-deploy-%d", i), namespace, fmt.Sprintf("burst-cm-%d", i)) + if _, err := client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create Deployment: %v", err) + } + } + + if err := waitForDeploymentsReady(setupCtx, client, namespace, 3*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + log.Println("S1: Starting burst updates...") + + updateCount := 0 + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + endTime := time.Now().Add(duration - 5*time.Second) + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + log.Printf("S1: Context cancelled, completed %d burst updates", updateCount) + return ExpectedMetrics{ + ActionTotal: updateCount, + ReloadExecutedTotal: updateCount, + WorkloadsMatchedTotal: updateCount, + Description: fmt.Sprintf("S1: %d burst updates, each triggers 1 deployment reload", updateCount), + }, nil + case <-ticker.C: + cmIndex := rand.Intn(numConfigMaps) + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, fmt.Sprintf("burst-cm-%d", cmIndex), metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["key"] = fmt.Sprintf("value-%d-%d", updateCount, time.Now().UnixNano()) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err != nil { + log.Printf("Failed to update ConfigMap: %v", err) + } else { + updateCount++ + } + } + } + + log.Printf("S1: Completed %d burst updates", updateCount) + return ExpectedMetrics{ + ActionTotal: updateCount, + ReloadExecutedTotal: updateCount, + WorkloadsMatchedTotal: updateCount, + Description: fmt.Sprintf("S1: %d burst updates, each triggers 1 deployment reload", updateCount), + }, nil +} + +// FanOutScenario - One ConfigMap used by many workloads. +type FanOutScenario struct{} + +func (s *FanOutScenario) Name() string { return "S2" } +func (s *FanOutScenario) Description() string { return "Fan-out (one CM -> many workloads)" } + +func (s *FanOutScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S2: Creating shared ConfigMap and multiple Deployments...") + + const numDeployments = 50 + setupCtx := context.Background() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shared-cm", + Namespace: namespace, + }, + Data: map[string]string{"config": "initial"}, + } + if _, err := client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}); err != nil { + return ExpectedMetrics{}, fmt.Errorf("failed to create shared ConfigMap: %w", err) + } + + for i := 0; i < numDeployments; i++ { + deploy := createDeployment(fmt.Sprintf("fanout-deploy-%d", i), namespace, "shared-cm") + if _, err := client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create Deployment %d: %v", i, err) + } + } + + if err := waitForDeploymentsReady(setupCtx, client, namespace, 5*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + log.Println("S2: Updating shared ConfigMap...") + + if ctx.Err() != nil { + log.Printf("S2: WARNING - Context already done before update loop: %v", ctx.Err()) + } + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + log.Printf("S2: Context deadline in %v", remaining) + if remaining < 10*time.Second { + log.Printf("S2: WARNING - Very little time remaining on context!") + } + } else { + log.Println("S2: Context has no deadline") + } + + updateCount := 0 + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + endTime := time.Now().Add(duration - 5*time.Second) + log.Printf("S2: Will run updates for %v (duration=%v)", duration-5*time.Second, duration) + + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + expectedActions := updateCount * numDeployments + log.Printf("S2: Context done (err=%v), completed %d fan-out updates", ctx.Err(), updateCount) + return ExpectedMetrics{ + ActionTotal: expectedActions, + ReloadExecutedTotal: expectedActions, + WorkloadsScannedTotal: expectedActions, + WorkloadsMatchedTotal: expectedActions, + Description: fmt.Sprintf("S2: %d updates × %d deployments = %d expected reloads", updateCount, numDeployments, expectedActions), + }, nil + case <-ticker.C: + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, "shared-cm", metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["config"] = fmt.Sprintf("update-%d", updateCount) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err != nil { + log.Printf("Failed to update shared ConfigMap: %v", err) + } else { + updateCount++ + log.Printf("S2: Updated shared ConfigMap (should trigger %d reloads)", numDeployments) + } + } + } + + expectedActions := updateCount * numDeployments + log.Printf("S2: Completed %d fan-out updates, expected %d total actions", updateCount, expectedActions) + return ExpectedMetrics{ + ActionTotal: expectedActions, + ReloadExecutedTotal: expectedActions, + WorkloadsScannedTotal: expectedActions, + WorkloadsMatchedTotal: expectedActions, + Description: fmt.Sprintf("S2: %d updates × %d deployments = %d expected reloads", updateCount, numDeployments, expectedActions), + }, nil +} + +// HighCardinalityScenario - Many ConfigMaps/Secrets across many namespaces. +type HighCardinalityScenario struct{} + +func (s *HighCardinalityScenario) Name() string { return "S3" } +func (s *HighCardinalityScenario) Description() string { + return "High cardinality (many CMs, many namespaces)" +} + +func (s *HighCardinalityScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S3: Creating high cardinality resources...") + + setupCtx := context.Background() + + namespaces := []string{namespace} + for i := 0; i < 10; i++ { + ns := fmt.Sprintf("%s-%d", namespace, i) + if _, err := client.CoreV1().Namespaces().Create(setupCtx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: ns}, + }, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create namespace %s: %v", ns, err) + } else { + namespaces = append(namespaces, ns) + } + } + + for _, ns := range namespaces { + for i := 0; i < 20; i++ { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("hc-cm-%d", i), + Namespace: ns, + }, + Data: map[string]string{"key": "value"}, + } + client.CoreV1().ConfigMaps(ns).Create(setupCtx, cm, metav1.CreateOptions{}) + deploy := createDeployment(fmt.Sprintf("hc-deploy-%d", i), ns, fmt.Sprintf("hc-cm-%d", i)) + client.AppsV1().Deployments(ns).Create(setupCtx, deploy, metav1.CreateOptions{}) + } + } + + if err := waitForAllNamespacesReady(setupCtx, client, namespaces, 5*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + log.Println("S3: Starting random updates across namespaces...") + + updateDuration := duration - 5*time.Second + if updateDuration < 30*time.Second { + updateDuration = 30 * time.Second + } + + updateCount := 0 + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + updateCtx, updateCancel := context.WithTimeout(context.Background(), updateDuration) + defer updateCancel() + + endTime := time.Now().Add(updateDuration) + log.Printf("S3: Will run updates for %v (until %v)", updateDuration, endTime.Format("15:04:05")) + + for time.Now().Before(endTime) { + select { + case <-updateCtx.Done(): + log.Printf("S3: Completed %d high cardinality updates", updateCount) + return ExpectedMetrics{ + ActionTotal: updateCount, + ReloadExecutedTotal: updateCount, + Description: fmt.Sprintf("S3: %d updates across %d namespaces", updateCount, len(namespaces)), + }, nil + case <-ticker.C: + ns := namespaces[rand.Intn(len(namespaces))] + cmIndex := rand.Intn(20) + cm, err := client.CoreV1().ConfigMaps(ns).Get(setupCtx, fmt.Sprintf("hc-cm-%d", cmIndex), metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["key"] = fmt.Sprintf("update-%d", updateCount) + if _, err := client.CoreV1().ConfigMaps(ns).Update(setupCtx, cm, metav1.UpdateOptions{}); err == nil { + updateCount++ + } + } + } + + log.Printf("S3: Completed %d high cardinality updates", updateCount) + return ExpectedMetrics{ + ActionTotal: updateCount, + ReloadExecutedTotal: updateCount, + Description: fmt.Sprintf("S3: %d updates across %d namespaces", updateCount, len(namespaces)), + }, nil +} + +// NoOpUpdateScenario - Updates that don't actually change data. +type NoOpUpdateScenario struct{} + +func (s *NoOpUpdateScenario) Name() string { return "S4" } +func (s *NoOpUpdateScenario) Description() string { return "No-op updates (same data)" } + +func (s *NoOpUpdateScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S4: Creating ConfigMaps and Deployments for no-op test...") + + setupCtx := context.Background() + + for i := 0; i < 10; i++ { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("noop-cm-%d", i), + Namespace: namespace, + }, + Data: map[string]string{"key": "static-value"}, + } + client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}) + deploy := createDeployment(fmt.Sprintf("noop-deploy-%d", i), namespace, fmt.Sprintf("noop-cm-%d", i)) + client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}) + } + + if err := waitForDeploymentsReady(setupCtx, client, namespace, 3*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + log.Println("S4: Starting no-op updates (annotation changes only)...") + + updateCount := 0 + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + endTime := time.Now().Add(duration - 5*time.Second) + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + log.Printf("S4: Completed %d no-op updates", updateCount) + return ExpectedMetrics{ + ActionTotal: 0, + ReloadExecutedTotal: 0, + SkippedTotal: updateCount, + Description: fmt.Sprintf("S4: %d no-op updates, all should be skipped", updateCount), + }, nil + case <-ticker.C: + cmIndex := rand.Intn(10) + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, fmt.Sprintf("noop-cm-%d", cmIndex), metav1.GetOptions{}) + if err != nil { + continue + } + if cm.Annotations == nil { + cm.Annotations = make(map[string]string) + } + cm.Annotations["noop-counter"] = fmt.Sprintf("%d", updateCount) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err == nil { + updateCount++ + } + } + } + + log.Printf("S4: Completed %d no-op updates (should see 0 actions)", updateCount) + return ExpectedMetrics{ + ActionTotal: 0, + ReloadExecutedTotal: 0, + SkippedTotal: updateCount, + Description: fmt.Sprintf("S4: %d no-op updates, all should be skipped", updateCount), + }, nil +} + +// WorkloadChurnScenario - Deployments created and deleted rapidly. +type WorkloadChurnScenario struct{} + +func (s *WorkloadChurnScenario) Name() string { return "S5" } +func (s *WorkloadChurnScenario) Description() string { return "Workload churn (rapid create/delete)" } + +func (s *WorkloadChurnScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S5: Creating base ConfigMap...") + + setupCtx := context.Background() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "churn-cm", Namespace: namespace}, + Data: map[string]string{"key": "value"}, + } + client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}) + + log.Println("S5: Starting workload churn...") + + var wg sync.WaitGroup + var mu sync.Mutex + deployCounter := 0 + deleteCounter := 0 + cmUpdateCount := 0 + + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + endTime := time.Now().Add(duration - 5*time.Second) + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + return + case <-ticker.C: + deployName := fmt.Sprintf("churn-deploy-%d", deployCounter) + deploy := createDeployment(deployName, namespace, "churn-cm") + if _, err := client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}); err == nil { + mu.Lock() + deployCounter++ + mu.Unlock() + } + if deployCounter > 10 { + oldName := fmt.Sprintf("churn-deploy-%d", deployCounter-10) + if err := client.AppsV1().Deployments(namespace).Delete(setupCtx, oldName, metav1.DeleteOptions{}); err == nil { + mu.Lock() + deleteCounter++ + mu.Unlock() + } + } + } + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + endTime := time.Now().Add(duration - 5*time.Second) + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + return + case <-ticker.C: + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, "churn-cm", metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["key"] = fmt.Sprintf("update-%d", cmUpdateCount) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err == nil { + mu.Lock() + cmUpdateCount++ + mu.Unlock() + } + } + } + }() + + wg.Wait() + log.Printf("S5: Created %d, deleted %d deployments, %d CM updates", deployCounter, deleteCounter, cmUpdateCount) + + return ExpectedMetrics{ + Description: fmt.Sprintf("S5: Churn test - %d deploys created, %d deleted, %d CM updates, ~10 active deploys at any time", deployCounter, deleteCounter, cmUpdateCount), + }, nil +} + +// ControllerRestartScenario - Restart controller under load. +type ControllerRestartScenario struct { + ReloaderVersion string +} + +func (s *ControllerRestartScenario) Name() string { return "S6" } +func (s *ControllerRestartScenario) Description() string { + return "Controller restart under load" +} + +func (s *ControllerRestartScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S6: Creating resources and generating load...") + + setupCtx := context.Background() + + for i := 0; i < 20; i++ { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("restart-cm-%d", i), + Namespace: namespace, + }, + Data: map[string]string{"key": "initial"}, + } + client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}) + deploy := createDeployment(fmt.Sprintf("restart-deploy-%d", i), namespace, fmt.Sprintf("restart-cm-%d", i)) + client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}) + } + + if err := waitForDeploymentsReady(setupCtx, client, namespace, 3*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + var wg sync.WaitGroup + var mu sync.Mutex + updateCount := 0 + + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + endTime := time.Now().Add(duration - 5*time.Second) + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + return + case <-ticker.C: + cmIndex := rand.Intn(20) + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, fmt.Sprintf("restart-cm-%d", cmIndex), metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["key"] = fmt.Sprintf("update-%d", updateCount) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err == nil { + mu.Lock() + updateCount++ + mu.Unlock() + } + } + } + }() + + reloaderNS := fmt.Sprintf("reloader-%s", s.ReloaderVersion) + if s.ReloaderVersion == "" { + reloaderNS = "reloader-new" + } + + log.Println("S6: Waiting 20 seconds before restarting controller...") + time.Sleep(20 * time.Second) + + log.Println("S6: Restarting Reloader pod...") + pods, err := client.CoreV1().Pods(reloaderNS).List(setupCtx, metav1.ListOptions{ + LabelSelector: "app=reloader", + }) + if err == nil && len(pods.Items) > 0 { + client.CoreV1().Pods(reloaderNS).Delete(setupCtx, pods.Items[0].Name, metav1.DeleteOptions{}) + } + + wg.Wait() + log.Printf("S6: Controller restart scenario completed with %d updates", updateCount) + return ExpectedMetrics{ + Description: fmt.Sprintf("S6: Restart test - %d updates during restart", updateCount), + }, nil +} + +// APIPressureScenario - Simulate API server pressure with many concurrent requests. +type APIPressureScenario struct{} + +func (s *APIPressureScenario) Name() string { return "S7" } +func (s *APIPressureScenario) Description() string { return "API pressure (many concurrent requests)" } + +func (s *APIPressureScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S7: Creating resources for API pressure test...") + + const numConfigMaps = 50 + setupCtx := context.Background() + + for i := 0; i < numConfigMaps; i++ { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("api-cm-%d", i), + Namespace: namespace, + }, + Data: map[string]string{"key": "value"}, + } + client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}) + deploy := createDeployment(fmt.Sprintf("api-deploy-%d", i), namespace, fmt.Sprintf("api-cm-%d", i)) + client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}) + } + + if err := waitForDeploymentsReady(setupCtx, client, namespace, 5*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + log.Println("S7: Starting concurrent updates from multiple goroutines...") + + updateDuration := duration - 5*time.Second + if updateDuration < 30*time.Second { + updateDuration = 30 * time.Second + } + + updateCtx, updateCancel := context.WithTimeout(context.Background(), updateDuration) + defer updateCancel() + + endTime := time.Now().Add(updateDuration) + log.Printf("S7: Will run updates for %v (until %v)", updateDuration, endTime.Format("15:04:05")) + + var wg sync.WaitGroup + var mu sync.Mutex + totalUpdates := 0 + + for g := 0; g < 10; g++ { + wg.Add(1) + go func(goroutineID int) { + defer wg.Done() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + updateCount := 0 + for time.Now().Before(endTime) { + select { + case <-updateCtx.Done(): + return + case <-ticker.C: + cmIndex := rand.Intn(numConfigMaps) + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, fmt.Sprintf("api-cm-%d", cmIndex), metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["key"] = fmt.Sprintf("g%d-update-%d", goroutineID, updateCount) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err == nil { + updateCount++ + } + } + } + mu.Lock() + totalUpdates += updateCount + mu.Unlock() + log.Printf("S7: Goroutine %d completed %d updates", goroutineID, updateCount) + }(g) + } + + wg.Wait() + log.Printf("S7: API pressure scenario completed with %d total updates", totalUpdates) + return ExpectedMetrics{ + ActionTotal: totalUpdates, + ReloadExecutedTotal: totalUpdates, + Description: fmt.Sprintf("S7: %d concurrent updates from 10 goroutines", totalUpdates), + }, nil +} + +// LargeObjectScenario - Large ConfigMaps/Secrets. +type LargeObjectScenario struct{} + +func (s *LargeObjectScenario) Name() string { return "S8" } +func (s *LargeObjectScenario) Description() string { return "Large ConfigMaps/Secrets (>100KB)" } + +func (s *LargeObjectScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S8: Creating large ConfigMaps...") + + setupCtx := context.Background() + + largeData := make([]byte, 100*1024) + for i := range largeData { + largeData[i] = byte('a' + (i % 26)) + } + largeValue := string(largeData) + + for i := 0; i < 10; i++ { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("large-cm-%d", i), + Namespace: namespace, + }, + Data: map[string]string{ + "large-key-1": largeValue, + "large-key-2": largeValue, + }, + } + if _, err := client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create large ConfigMap %d: %v", i, err) + } + deploy := createDeployment(fmt.Sprintf("large-deploy-%d", i), namespace, fmt.Sprintf("large-cm-%d", i)) + client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}) + } + + if err := waitForDeploymentsReady(setupCtx, client, namespace, 3*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + log.Println("S8: Starting large object updates...") + + updateCount := 0 + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + endTime := time.Now().Add(duration - 5*time.Second) + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + log.Printf("S8: Completed %d large object updates", updateCount) + return ExpectedMetrics{ + ActionTotal: updateCount, + ReloadExecutedTotal: updateCount, + Description: fmt.Sprintf("S8: %d large object (100KB) updates", updateCount), + }, nil + case <-ticker.C: + cmIndex := rand.Intn(10) + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, fmt.Sprintf("large-cm-%d", cmIndex), metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["large-key-1"] = largeValue[:len(largeValue)-10] + fmt.Sprintf("-%d", updateCount) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err != nil { + log.Printf("Failed to update large ConfigMap: %v", err) + } else { + updateCount++ + } + } + } + + log.Printf("S8: Completed %d large object updates", updateCount) + return ExpectedMetrics{ + ActionTotal: updateCount, + ReloadExecutedTotal: updateCount, + Description: fmt.Sprintf("S8: %d large object (100KB) updates", updateCount), + }, nil +} + +func waitForDeploymentsReady(ctx context.Context, client kubernetes.Interface, namespace string, timeout time.Duration) error { + log.Printf("Waiting for all deployments in %s to be ready (timeout: %v)...", namespace, timeout) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + deployments, err := client.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("failed to list deployments: %w", err) + } + + allReady := true + notReady := 0 + for _, d := range deployments.Items { + if d.Status.ReadyReplicas < *d.Spec.Replicas { + allReady = false + notReady++ + } + } + + if allReady && len(deployments.Items) > 0 { + log.Printf("All %d deployments in %s are ready", len(deployments.Items), namespace) + return nil + } + + log.Printf("Waiting for deployments: %d/%d not ready yet...", notReady, len(deployments.Items)) + time.Sleep(5 * time.Second) + } + + return fmt.Errorf("timeout waiting for deployments to be ready") +} + +func waitForAllNamespacesReady(ctx context.Context, client kubernetes.Interface, namespaces []string, timeout time.Duration) error { + log.Printf("Waiting for deployments in %d namespaces to be ready...", len(namespaces)) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + allReady := true + totalDeploys := 0 + notReady := 0 + + for _, ns := range namespaces { + deployments, err := client.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + continue + } + for _, d := range deployments.Items { + totalDeploys++ + if d.Status.ReadyReplicas < *d.Spec.Replicas { + allReady = false + notReady++ + } + } + } + + if allReady && totalDeploys > 0 { + log.Printf("All %d deployments across %d namespaces are ready", totalDeploys, len(namespaces)) + return nil + } + + log.Printf("Waiting: %d/%d deployments not ready yet...", notReady, totalDeploys) + time.Sleep(5 * time.Second) + } + + return fmt.Errorf("timeout waiting for deployments to be ready") +} + +func createDeployment(name, namespace, configMapName string) *appsv1.Deployment { + replicas := int32(1) + maxSurge := intstr.FromInt(1) + maxUnavailable := intstr.FromInt(1) + terminationGracePeriod := int64(0) + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: map[string]string{ + "reloader.stakater.com/auto": "true", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{ + MaxSurge: &maxSurge, + MaxUnavailable: &maxUnavailable, + }, + }, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: &terminationGracePeriod, + Containers: []corev1.Container{ + { + Name: "app", + Image: "gcr.io/google-containers/busybox:1.27", + Command: []string{"sh", "-c", "sleep 999999999"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1m"), + corev1.ResourceMemory: resource.MustParse("4Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + }, + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func createDeploymentWithSecret(name, namespace, secretName string) *appsv1.Deployment { + replicas := int32(1) + maxSurge := intstr.FromInt(1) + maxUnavailable := intstr.FromInt(1) + terminationGracePeriod := int64(0) + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: map[string]string{ + "reloader.stakater.com/auto": "true", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{ + MaxSurge: &maxSurge, + MaxUnavailable: &maxUnavailable, + }, + }, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: &terminationGracePeriod, + Containers: []corev1.Container{ + { + Name: "app", + Image: "gcr.io/google-containers/busybox:1.27", + Command: []string{"sh", "-c", "sleep 999999999"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1m"), + corev1.ResourceMemory: resource.MustParse("4Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + }, + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretName, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func createDeploymentWithBoth(name, namespace, configMapName, secretName string) *appsv1.Deployment { + replicas := int32(1) + maxSurge := intstr.FromInt(1) + maxUnavailable := intstr.FromInt(1) + terminationGracePeriod := int64(0) + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: map[string]string{ + "reloader.stakater.com/auto": "true", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{ + MaxSurge: &maxSurge, + MaxUnavailable: &maxUnavailable, + }, + }, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: &terminationGracePeriod, + Containers: []corev1.Container{ + { + Name: "app", + Image: "gcr.io/google-containers/busybox:1.27", + Command: []string{"sh", "-c", "sleep 999999999"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1m"), + corev1.ResourceMemory: resource.MustParse("4Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + }, + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + }, + }, + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secretName, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// SecretsAndMixedScenario - Tests Secrets and mixed ConfigMap+Secret workloads. +type SecretsAndMixedScenario struct{} + +func (s *SecretsAndMixedScenario) Name() string { return "S10" } +func (s *SecretsAndMixedScenario) Description() string { + return "Secrets and mixed ConfigMap+Secret workloads" +} + +func (s *SecretsAndMixedScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S10: Creating Secrets, ConfigMaps, and mixed workloads...") + + const numSecrets = 5 + const numConfigMaps = 5 + const numSecretOnlyDeploys = 5 + const numConfigMapOnlyDeploys = 3 + const numMixedDeploys = 2 + + setupCtx := context.Background() + + for i := 0; i < numSecrets; i++ { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("mixed-secret-%d", i), + Namespace: namespace, + }, + StringData: map[string]string{ + "password": fmt.Sprintf("initial-secret-%d", i), + }, + } + if _, err := client.CoreV1().Secrets(namespace).Create(setupCtx, secret, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create Secret %s: %v", secret.Name, err) + } + } + + for i := 0; i < numConfigMaps; i++ { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("mixed-cm-%d", i), + Namespace: namespace, + }, + Data: map[string]string{ + "config": fmt.Sprintf("initial-config-%d", i), + }, + } + if _, err := client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create ConfigMap %s: %v", cm.Name, err) + } + } + + for i := 0; i < numSecretOnlyDeploys; i++ { + deploy := createDeploymentWithSecret( + fmt.Sprintf("secret-only-deploy-%d", i), + namespace, + fmt.Sprintf("mixed-secret-%d", i%numSecrets), + ) + if _, err := client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create Secret-only Deployment: %v", err) + } + } + + for i := 0; i < numConfigMapOnlyDeploys; i++ { + deploy := createDeployment( + fmt.Sprintf("cm-only-deploy-%d", i), + namespace, + fmt.Sprintf("mixed-cm-%d", i%numConfigMaps), + ) + if _, err := client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create ConfigMap-only Deployment: %v", err) + } + } + + for i := 0; i < numMixedDeploys; i++ { + deploy := createDeploymentWithBoth( + fmt.Sprintf("mixed-deploy-%d", i), + namespace, + fmt.Sprintf("mixed-cm-%d", i%numConfigMaps), + fmt.Sprintf("mixed-secret-%d", i%numSecrets), + ) + if _, err := client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create mixed Deployment: %v", err) + } + } + + if err := waitForDeploymentsReady(setupCtx, client, namespace, 3*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + log.Println("S10: Starting alternating Secret and ConfigMap updates...") + + secretUpdateCount := 0 + cmUpdateCount := 0 + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + updateSecret := true + + endTime := time.Now().Add(duration - 5*time.Second) + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + return s.calculateExpected(secretUpdateCount, cmUpdateCount, numSecrets, numConfigMaps, numSecretOnlyDeploys, numConfigMapOnlyDeploys, numMixedDeploys), nil + case <-ticker.C: + if updateSecret { + secretIndex := rand.Intn(numSecrets) + secret, err := client.CoreV1().Secrets(namespace).Get(setupCtx, fmt.Sprintf("mixed-secret-%d", secretIndex), metav1.GetOptions{}) + if err != nil { + continue + } + secret.StringData = map[string]string{ + "password": fmt.Sprintf("updated-secret-%d-%d", secretIndex, secretUpdateCount), + } + if _, err := client.CoreV1().Secrets(namespace).Update(setupCtx, secret, metav1.UpdateOptions{}); err == nil { + secretUpdateCount++ + } + } else { + cmIndex := rand.Intn(numConfigMaps) + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, fmt.Sprintf("mixed-cm-%d", cmIndex), metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["config"] = fmt.Sprintf("updated-config-%d-%d", cmIndex, cmUpdateCount) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err == nil { + cmUpdateCount++ + } + } + updateSecret = !updateSecret + } + } + + log.Printf("S10: Completed %d Secret updates and %d ConfigMap updates", secretUpdateCount, cmUpdateCount) + return s.calculateExpected(secretUpdateCount, cmUpdateCount, numSecrets, numConfigMaps, numSecretOnlyDeploys, numConfigMapOnlyDeploys, numMixedDeploys), nil +} + +func (s *SecretsAndMixedScenario) calculateExpected(secretUpdates, cmUpdates, numSecrets, numConfigMaps, secretOnlyDeploys, cmOnlyDeploys, mixedDeploys int) ExpectedMetrics { + avgSecretReloads := float64(secretOnlyDeploys)/float64(numSecrets) + float64(mixedDeploys)/float64(numSecrets) + secretTriggeredReloads := int(float64(secretUpdates) * avgSecretReloads) + + avgCMReloads := float64(cmOnlyDeploys)/float64(numConfigMaps) + float64(mixedDeploys)/float64(numConfigMaps) + cmTriggeredReloads := int(float64(cmUpdates) * avgCMReloads) + + totalExpectedReloads := secretTriggeredReloads + cmTriggeredReloads + + return ExpectedMetrics{ + ActionTotal: totalExpectedReloads, + ReloadExecutedTotal: totalExpectedReloads, + Description: fmt.Sprintf("S10: %d Secret updates (→%d reloads, avg %.1f/update) + %d CM updates (→%d reloads, avg %.1f/update) = %d total", + secretUpdates, secretTriggeredReloads, avgSecretReloads, cmUpdates, cmTriggeredReloads, avgCMReloads, totalExpectedReloads), + } +} + +// MultiWorkloadTypeScenario - Tests all supported workload types with a shared ConfigMap. +type MultiWorkloadTypeScenario struct{} + +func (s *MultiWorkloadTypeScenario) Name() string { return "S9" } +func (s *MultiWorkloadTypeScenario) Description() string { + return "Multi-workload types (Deploy, StatefulSet, DaemonSet, Job, CronJob)" +} + +func (s *MultiWorkloadTypeScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S9: Creating shared ConfigMap and multiple workload types...") + + const numDeployments = 5 + const numStatefulSets = 3 + const numDaemonSets = 2 + + setupCtx := context.Background() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "multi-type-cm", + Namespace: namespace, + }, + Data: map[string]string{"config": "initial"}, + } + if _, err := client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}); err != nil { + return ExpectedMetrics{}, fmt.Errorf("failed to create shared ConfigMap: %w", err) + } + + for i := 0; i < numDeployments; i++ { + deploy := createDeployment(fmt.Sprintf("multi-deploy-%d", i), namespace, "multi-type-cm") + if _, err := client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create Deployment %d: %v", i, err) + } + } + + for i := 0; i < numStatefulSets; i++ { + sts := createStatefulSet(fmt.Sprintf("multi-sts-%d", i), namespace, "multi-type-cm") + if _, err := client.AppsV1().StatefulSets(namespace).Create(setupCtx, sts, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create StatefulSet %d: %v", i, err) + } + } + + for i := 0; i < numDaemonSets; i++ { + ds := createDaemonSet(fmt.Sprintf("multi-ds-%d", i), namespace, "multi-type-cm") + if _, err := client.AppsV1().DaemonSets(namespace).Create(setupCtx, ds, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create DaemonSet %d: %v", i, err) + } + } + + if err := waitForDeploymentsReady(setupCtx, client, namespace, 3*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + if err := waitForStatefulSetsReady(setupCtx, client, namespace, 3*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + if err := waitForDaemonSetsReady(setupCtx, client, namespace, 3*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + log.Println("S9: Starting ConfigMap updates to trigger reloads on all workload types...") + + updateCount := 0 + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + endTime := time.Now().Add(duration - 5*time.Second) + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + return s.calculateExpected(updateCount, numDeployments, numStatefulSets, numDaemonSets), nil + case <-ticker.C: + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, "multi-type-cm", metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["config"] = fmt.Sprintf("update-%d", updateCount) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err != nil { + log.Printf("Failed to update shared ConfigMap: %v", err) + } else { + updateCount++ + log.Printf("S9: Updated shared ConfigMap (update #%d)", updateCount) + } + } + } + + log.Printf("S9: Completed %d ConfigMap updates", updateCount) + return s.calculateExpected(updateCount, numDeployments, numStatefulSets, numDaemonSets), nil +} + +func (s *MultiWorkloadTypeScenario) calculateExpected(updateCount, numDeployments, numStatefulSets, numDaemonSets int) ExpectedMetrics { + totalWorkloads := numDeployments + numStatefulSets + numDaemonSets + expectedReloads := updateCount * totalWorkloads + + return ExpectedMetrics{ + ActionTotal: expectedReloads, + ReloadExecutedTotal: expectedReloads, + WorkloadsMatchedTotal: expectedReloads, + Description: fmt.Sprintf("S9: %d CM updates × %d workloads (%d Deploys + %d STS + %d DS) = %d reloads", + updateCount, totalWorkloads, numDeployments, numStatefulSets, numDaemonSets, expectedReloads), + } +} + +func createStatefulSet(name, namespace, configMapName string) *appsv1.StatefulSet { + replicas := int32(1) + terminationGracePeriod := int64(0) + + return &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: map[string]string{ + "reloader.stakater.com/auto": "true", + }, + }, + Spec: appsv1.StatefulSetSpec{ + Replicas: &replicas, + ServiceName: name, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: &terminationGracePeriod, + Containers: []corev1.Container{ + { + Name: "app", + Image: "gcr.io/google-containers/busybox:1.27", + Command: []string{"sh", "-c", "sleep 999999999"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1m"), + corev1.ResourceMemory: resource.MustParse("4Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + }, + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func createDaemonSet(name, namespace, configMapName string) *appsv1.DaemonSet { + terminationGracePeriod := int64(0) + + return &appsv1.DaemonSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: map[string]string{ + "reloader.stakater.com/auto": "true", + }, + }, + Spec: appsv1.DaemonSetSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: &terminationGracePeriod, + Tolerations: []corev1.Toleration{ + { + Key: "node-role.kubernetes.io/control-plane", + Operator: corev1.TolerationOpExists, + Effect: corev1.TaintEffectNoSchedule, + }, + { + Key: "node-role.kubernetes.io/master", + Operator: corev1.TolerationOpExists, + Effect: corev1.TaintEffectNoSchedule, + }, + }, + Containers: []corev1.Container{ + { + Name: "app", + Image: "gcr.io/google-containers/busybox:1.27", + Command: []string{"sh", "-c", "sleep 999999999"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1m"), + corev1.ResourceMemory: resource.MustParse("4Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + }, + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func waitForStatefulSetsReady(ctx context.Context, client kubernetes.Interface, namespace string, timeout time.Duration) error { + log.Printf("Waiting for all StatefulSets in %s to be ready (timeout: %v)...", namespace, timeout) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + stsList, err := client.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("failed to list StatefulSets: %w", err) + } + + if len(stsList.Items) == 0 { + log.Printf("No StatefulSets found in %s", namespace) + return nil + } + + allReady := true + notReady := 0 + for _, sts := range stsList.Items { + if sts.Status.ReadyReplicas < *sts.Spec.Replicas { + allReady = false + notReady++ + } + } + + if allReady { + log.Printf("All %d StatefulSets in %s are ready", len(stsList.Items), namespace) + return nil + } + + log.Printf("Waiting for StatefulSets: %d/%d not ready yet...", notReady, len(stsList.Items)) + time.Sleep(5 * time.Second) + } + + return fmt.Errorf("timeout waiting for StatefulSets to be ready") +} + +func waitForDaemonSetsReady(ctx context.Context, client kubernetes.Interface, namespace string, timeout time.Duration) error { + log.Printf("Waiting for all DaemonSets in %s to be ready (timeout: %v)...", namespace, timeout) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + dsList, err := client.AppsV1().DaemonSets(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("failed to list DaemonSets: %w", err) + } + + if len(dsList.Items) == 0 { + log.Printf("No DaemonSets found in %s", namespace) + return nil + } + + allReady := true + notReady := 0 + for _, ds := range dsList.Items { + if ds.Status.NumberReady < ds.Status.DesiredNumberScheduled { + allReady = false + notReady++ + } + } + + if allReady { + log.Printf("All %d DaemonSets in %s are ready", len(dsList.Items), namespace) + return nil + } + + log.Printf("Waiting for DaemonSets: %d/%d not ready yet...", notReady, len(dsList.Items)) + time.Sleep(5 * time.Second) + } + + return fmt.Errorf("timeout waiting for DaemonSets to be ready") +} + +// ComplexReferencesScenario - Tests init containers, valueFrom, and projected volumes. +type ComplexReferencesScenario struct{} + +func (s *ComplexReferencesScenario) Name() string { return "S13" } +func (s *ComplexReferencesScenario) Description() string { + return "Complex references (init containers, valueFrom, projected volumes)" +} + +func (s *ComplexReferencesScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S13: Creating ConfigMaps and complex deployments with various reference types...") + + const numConfigMaps = 5 + const numDeployments = 5 + + setupCtx := context.Background() + + for i := 0; i < numConfigMaps; i++ { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("complex-cm-%d", i), + Namespace: namespace, + }, + Data: map[string]string{ + "key1": fmt.Sprintf("value1-%d", i), + "key2": fmt.Sprintf("value2-%d", i), + "config": fmt.Sprintf("config-%d", i), + }, + } + if _, err := client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create ConfigMap %s: %v", cm.Name, err) + } + } + + for i := 0; i < numDeployments; i++ { + primaryCM := fmt.Sprintf("complex-cm-%d", i) + secondaryCM := fmt.Sprintf("complex-cm-%d", (i+1)%numConfigMaps) + + deploy := createComplexDeployment( + fmt.Sprintf("complex-deploy-%d", i), + namespace, + primaryCM, + secondaryCM, + ) + if _, err := client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create complex Deployment: %v", err) + } + } + + if err := waitForDeploymentsReady(setupCtx, client, namespace, 3*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + log.Println("S13: Starting ConfigMap updates to test all reference types...") + + updateCount := 0 + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + endTime := time.Now().Add(duration - 5*time.Second) + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + return s.calculateExpected(updateCount, numConfigMaps, numDeployments), nil + case <-ticker.C: + cmIndex := rand.Intn(numConfigMaps) + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, fmt.Sprintf("complex-cm-%d", cmIndex), metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["key1"] = fmt.Sprintf("updated-value1-%d-%d", cmIndex, updateCount) + cm.Data["config"] = fmt.Sprintf("updated-config-%d-%d", cmIndex, updateCount) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err != nil { + log.Printf("Failed to update ConfigMap: %v", err) + } else { + updateCount++ + log.Printf("S13: Updated complex-cm-%d (update #%d)", cmIndex, updateCount) + } + } + } + + log.Printf("S13: Completed %d ConfigMap updates", updateCount) + return s.calculateExpected(updateCount, numConfigMaps, numDeployments), nil +} + +func (s *ComplexReferencesScenario) calculateExpected(updateCount, numConfigMaps, numDeployments int) ExpectedMetrics { + expectedReloadsPerUpdate := 2 + expectedReloads := updateCount * expectedReloadsPerUpdate + + return ExpectedMetrics{ + ActionTotal: expectedReloads, + ReloadExecutedTotal: expectedReloads, + Description: fmt.Sprintf("S13: %d CM updates × ~%d affected deploys = ~%d reloads (init containers, valueFrom, volumes, projected)", + updateCount, expectedReloadsPerUpdate, expectedReloads), + } +} + +// PauseResumeScenario - Tests pause-period functionality under rapid updates. +type PauseResumeScenario struct{} + +func (s *PauseResumeScenario) Name() string { return "S12" } +func (s *PauseResumeScenario) Description() string { + return "Pause & Resume (rapid updates with pause-period)" +} + +func (s *PauseResumeScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + log.Println("S12: Creating ConfigMaps and Deployments with pause-period annotation...") + + const numConfigMaps = 10 + const numDeployments = 10 + const pausePeriod = 15 * time.Second + const updateInterval = 2 * time.Second + + setupCtx := context.Background() + + for i := 0; i < numConfigMaps; i++ { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("pause-cm-%d", i), + Namespace: namespace, + }, + Data: map[string]string{"key": "initial-value"}, + } + if _, err := client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create ConfigMap %s: %v", cm.Name, err) + } + } + + for i := 0; i < numDeployments; i++ { + deploy := createDeploymentWithPause( + fmt.Sprintf("pause-deploy-%d", i), + namespace, + fmt.Sprintf("pause-cm-%d", i), + pausePeriod, + ) + if _, err := client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create Deployment: %v", err) + } + } + + if err := waitForDeploymentsReady(setupCtx, client, namespace, 3*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + log.Printf("S12: Starting rapid ConfigMap updates (every %v) with %v pause-period...", updateInterval, pausePeriod) + + updateCount := 0 + ticker := time.NewTicker(updateInterval) + defer ticker.Stop() + + endTime := time.Now().Add(duration - 5*time.Second) + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + return s.calculateExpected(updateCount, duration, updateInterval, pausePeriod), nil + case <-ticker.C: + cmIndex := rand.Intn(numConfigMaps) + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, fmt.Sprintf("pause-cm-%d", cmIndex), metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["key"] = fmt.Sprintf("update-%d-%d", cmIndex, updateCount) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err != nil { + log.Printf("Failed to update ConfigMap: %v", err) + } else { + updateCount++ + } + } + } + + log.Printf("S12: Completed %d rapid updates (pause-period should reduce actual reloads)", updateCount) + return s.calculateExpected(updateCount, duration, updateInterval, pausePeriod), nil +} + +func (s *PauseResumeScenario) calculateExpected(updateCount int, duration, updateInterval, pausePeriod time.Duration) ExpectedMetrics { + + // This is an approximation - the actual value depends on random distribution + expectedCycles := int(duration / pausePeriod) + if expectedCycles < 1 { + expectedCycles = 1 + } + + return ExpectedMetrics{ + Description: fmt.Sprintf("S12: %d updates with %v pause-period (expect ~%d reload cycles, actual reloads << updates)", + updateCount, pausePeriod, expectedCycles), + } +} + +// AnnotationStrategyScenario - Tests annotation-based reload strategy. +// This scenario deploys its own Reloader instance with --reload-strategy=annotations. +type AnnotationStrategyScenario struct { + Image string +} + +func (s *AnnotationStrategyScenario) Name() string { return "S11" } +func (s *AnnotationStrategyScenario) Description() string { + return "Annotation reload strategy (--reload-strategy=annotations)" +} + +func (s *AnnotationStrategyScenario) Run(ctx context.Context, client kubernetes.Interface, namespace string, duration time.Duration) (ExpectedMetrics, error) { + if s.Image == "" { + return ExpectedMetrics{}, fmt.Errorf("S11 requires Image to be set (use the same image as --new-image)") + } + + log.Println("S11: Deploying Reloader with --reload-strategy=annotations...") + + reloaderNS := "reloader-s11" + mgr := reloader.NewManager(reloader.Config{ + Version: "s11", + Image: s.Image, + Namespace: reloaderNS, + ReloadStrategy: "annotations", + }) + + if err := mgr.Deploy(ctx); err != nil { + return ExpectedMetrics{}, fmt.Errorf("deploying S11 reloader: %w", err) + } + + defer func() { + log.Println("S11: Cleaning up S11-specific Reloader...") + cleanupCtx := context.Background() + if err := mgr.Cleanup(cleanupCtx); err != nil { + log.Printf("Warning: failed to cleanup S11 reloader: %v", err) + } + }() + + log.Println("S11: Creating ConfigMaps and Deployments...") + + const numConfigMaps = 10 + const numDeployments = 10 + + setupCtx := context.Background() + + for i := 0; i < numConfigMaps; i++ { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("annot-cm-%d", i), + Namespace: namespace, + }, + Data: map[string]string{"key": "initial-value"}, + } + if _, err := client.CoreV1().ConfigMaps(namespace).Create(setupCtx, cm, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create ConfigMap %s: %v", cm.Name, err) + } + } + + for i := 0; i < numDeployments; i++ { + deploy := createDeployment(fmt.Sprintf("annot-deploy-%d", i), namespace, fmt.Sprintf("annot-cm-%d", i)) + if _, err := client.AppsV1().Deployments(namespace).Create(setupCtx, deploy, metav1.CreateOptions{}); err != nil { + log.Printf("Failed to create Deployment: %v", err) + } + } + + if err := waitForDeploymentsReady(setupCtx, client, namespace, 3*time.Minute); err != nil { + log.Printf("Warning: %v - continuing anyway", err) + } + + log.Println("S11: Starting ConfigMap updates with annotation strategy...") + + updateCount := 0 + annotationUpdatesSeen := 0 + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + endTime := time.Now().Add(duration - 10*time.Second) + for time.Now().Before(endTime) { + select { + case <-ctx.Done(): + return s.calculateExpected(updateCount, annotationUpdatesSeen), nil + case <-ticker.C: + cmIndex := rand.Intn(numConfigMaps) + cm, err := client.CoreV1().ConfigMaps(namespace).Get(setupCtx, fmt.Sprintf("annot-cm-%d", cmIndex), metav1.GetOptions{}) + if err != nil { + continue + } + cm.Data["key"] = fmt.Sprintf("update-%d-%d", cmIndex, updateCount) + if _, err := client.CoreV1().ConfigMaps(namespace).Update(setupCtx, cm, metav1.UpdateOptions{}); err != nil { + log.Printf("Failed to update ConfigMap: %v", err) + } else { + updateCount++ + } + + if updateCount%10 == 0 { + deploy, err := client.AppsV1().Deployments(namespace).Get(setupCtx, fmt.Sprintf("annot-deploy-%d", cmIndex), metav1.GetOptions{}) + if err == nil { + if _, hasAnnotation := deploy.Spec.Template.Annotations["reloader.stakater.com/last-reloaded-from"]; hasAnnotation { + annotationUpdatesSeen++ + } + } + } + } + } + + log.Println("S11: Verifying annotation-based reload...") + time.Sleep(5 * time.Second) + + deploysWithAnnotation := 0 + for i := 0; i < numDeployments; i++ { + deploy, err := client.AppsV1().Deployments(namespace).Get(setupCtx, fmt.Sprintf("annot-deploy-%d", i), metav1.GetOptions{}) + if err != nil { + continue + } + if deploy.Spec.Template.Annotations != nil { + if _, ok := deploy.Spec.Template.Annotations["reloader.stakater.com/last-reloaded-from"]; ok { + deploysWithAnnotation++ + } + } + } + + log.Printf("S11: Completed %d updates, %d deployments have reload annotation", updateCount, deploysWithAnnotation) + return s.calculateExpected(updateCount, deploysWithAnnotation), nil +} + +func (s *AnnotationStrategyScenario) calculateExpected(updateCount, deploysWithAnnotation int) ExpectedMetrics { + return ExpectedMetrics{ + ActionTotal: updateCount, + ReloadExecutedTotal: updateCount, + Description: fmt.Sprintf("S11: %d updates with annotation strategy, %d deployments received annotation", + updateCount, deploysWithAnnotation), + } +} + +func createDeploymentWithPause(name, namespace, configMapName string, pausePeriod time.Duration) *appsv1.Deployment { + replicas := int32(1) + maxSurge := intstr.FromInt(1) + maxUnavailable := intstr.FromInt(1) + terminationGracePeriod := int64(0) + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: map[string]string{ + "reloader.stakater.com/auto": "true", + // Deployment-specific pause-period annotation + "deployment.reloader.stakater.com/pause-period": fmt.Sprintf("%ds", int(pausePeriod.Seconds())), + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{ + MaxSurge: &maxSurge, + MaxUnavailable: &maxUnavailable, + }, + }, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: &terminationGracePeriod, + Containers: []corev1.Container{ + { + Name: "app", + Image: "gcr.io/google-containers/busybox:1.27", + Command: []string{"sh", "-c", "sleep 999999999"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1m"), + corev1.ResourceMemory: resource.MustParse("4Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + }, + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: configMapName, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// createComplexDeployment creates a deployment with multiple ConfigMap reference types. +// - Init container using envFrom +// - Main container using env.valueFrom.configMapKeyRef +// - Sidecar container using volume mount +// - Projected volume combining multiple ConfigMaps +func createComplexDeployment(name, namespace, primaryCM, secondaryCM string) *appsv1.Deployment { + replicas := int32(1) + maxSurge := intstr.FromInt(1) + maxUnavailable := intstr.FromInt(1) + terminationGracePeriod := int64(0) + + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Annotations: map[string]string{ + "reloader.stakater.com/auto": "true", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Strategy: appsv1.DeploymentStrategy{ + Type: appsv1.RollingUpdateDeploymentStrategyType, + RollingUpdate: &appsv1.RollingUpdateDeployment{ + MaxSurge: &maxSurge, + MaxUnavailable: &maxUnavailable, + }, + }, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: &terminationGracePeriod, + InitContainers: []corev1.Container{ + { + Name: "init", + Image: "gcr.io/google-containers/busybox:1.27", + Command: []string{"sh", "-c", "echo Init done"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1m"), + corev1.ResourceMemory: resource.MustParse("4Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + }, + EnvFrom: []corev1.EnvFromSource{ + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: primaryCM, + }, + }, + }, + }, + }, + }, + Containers: []corev1.Container{ + { + Name: "main", + Image: "gcr.io/google-containers/busybox:1.27", + Command: []string{"sh", "-c", "sleep 999999999"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1m"), + corev1.ResourceMemory: resource.MustParse("4Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + }, + Env: []corev1.EnvVar{ + { + Name: "CONFIG_KEY1", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: primaryCM, + }, + Key: "key1", + }, + }, + }, + { + Name: "CONFIG_KEY2", + ValueFrom: &corev1.EnvVarSource{ + ConfigMapKeyRef: &corev1.ConfigMapKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: primaryCM, + }, + Key: "key2", + }, + }, + }, + }, + }, + { + Name: "sidecar", + Image: "gcr.io/google-containers/busybox:1.27", + Command: []string{"sh", "-c", "sleep 999999999"}, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1m"), + corev1.ResourceMemory: resource.MustParse("4Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "config-volume", + MountPath: "/etc/config", + }, + { + Name: "projected-volume", + MountPath: "/etc/projected", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "config-volume", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: primaryCM, + }, + }, + }, + }, + { + Name: "projected-volume", + VolumeSource: corev1.VolumeSource{ + Projected: &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{ + { + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: primaryCM, + }, + Items: []corev1.KeyToPath{ + { + Key: "key1", + Path: "primary-key1", + }, + }, + }, + }, + { + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: secondaryCM, + }, + Items: []corev1.KeyToPath{ + { + Key: "key1", + Path: "secondary-key1", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} diff --git a/test/loadtest/manifests/prometheus.yaml b/test/loadtest/manifests/prometheus.yaml new file mode 100644 index 00000000..f826f52f --- /dev/null +++ b/test/loadtest/manifests/prometheus.yaml @@ -0,0 +1,181 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-config + namespace: monitoring +data: + prometheus.yml: | + global: + scrape_interval: 2s + evaluation_interval: 2s + + scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'reloader-old' + kubernetes_sd_configs: + - role: pod + namespaces: + names: + - reloader-old + relabel_configs: + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape] + action: keep + regex: true + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path] + action: replace + target_label: __metrics_path__ + regex: (.+) + - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port] + action: replace + regex: ([^:]+)(?::\d+)?;(\d+) + replacement: $1:$2 + target_label: __address__ + - action: labelmap + regex: __meta_kubernetes_pod_label_(.+) + - source_labels: [__meta_kubernetes_namespace] + action: replace + target_label: kubernetes_namespace + - source_labels: [__meta_kubernetes_pod_name] + action: replace + target_label: kubernetes_pod_name + + - job_name: 'reloader-new' + kubernetes_sd_configs: + - role: pod + namespaces: + names: + - reloader-new + relabel_configs: + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape] + action: keep + regex: true + - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path] + action: replace + target_label: __metrics_path__ + regex: (.+) + - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port] + action: replace + regex: ([^:]+)(?::\d+)?;(\d+) + replacement: $1:$2 + target_label: __address__ + - action: labelmap + regex: __meta_kubernetes_pod_label_(.+) + - source_labels: [__meta_kubernetes_namespace] + action: replace + target_label: kubernetes_namespace + - source_labels: [__meta_kubernetes_pod_name] + action: replace + target_label: kubernetes_pod_name +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: prometheus + namespace: monitoring +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: prometheus +rules: + - apiGroups: [""] + resources: + - nodes + - nodes/proxy + - services + - endpoints + - pods + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: + - configmaps + verbs: ["get"] + - nonResourceURLs: ["/metrics"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: prometheus +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: prometheus +subjects: + - kind: ServiceAccount + name: prometheus + namespace: monitoring +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus + namespace: monitoring +spec: + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + labels: + app: prometheus + spec: + serviceAccountName: prometheus + containers: + - name: prometheus + image: quay.io/prometheus/prometheus:v2.47.0 + args: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.console.libraries=/usr/share/prometheus/console_libraries + - --web.console.templates=/usr/share/prometheus/consoles + - --web.enable-lifecycle + ports: + - containerPort: 9090 + volumeMounts: + - name: config + mountPath: /etc/prometheus + - name: data + mountPath: /prometheus + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 200m + memory: 512Mi + readinessProbe: + httpGet: + path: /-/ready + port: 9090 + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /-/healthy + port: 9090 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: config + configMap: + name: prometheus-config + - name: data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: prometheus + namespace: monitoring +spec: + selector: + app: prometheus + ports: + - port: 9090 + targetPort: 9090 + type: NodePort diff --git a/theme_common b/theme_common deleted file mode 160000 index 11286e11..00000000 --- a/theme_common +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 11286e112ea55c995232ea415038456ab3f70f59 diff --git a/theme_override/mkdocs.yml b/theme_override/mkdocs.yml deleted file mode 100644 index 265ec602..00000000 --- a/theme_override/mkdocs.yml +++ /dev/null @@ -1,22 +0,0 @@ -site_name: Stakater Reloader -docs_dir: docs -site_url: https://docs.stakater.com/reloader/ -repo_url: https://github.com/stakater/reloader -edit_uri: blob/master/docs/ - -theme: - favicon: assets/images/favicon.svg - -nav: - - index.md - - How-to Guides: - - Verify-Reloader-Working.md - - Alerting.md - - Reloader-with-Sealed-Secrets.md - - Helm2-to-Helm3.md - - References: - - How-it-works.md - - Container Build.md - - Comparisons with similar tools: - - Reloader-vs-ConfigmapController.md - - Reloader-vs-k8s-trigger-controller.md diff --git a/theme_override/resources/.gitignore b/theme_override/resources/.gitignore deleted file mode 100644 index e69de29b..00000000 diff --git a/theme_override/resources/assets/images/favicon.svg b/theme_override/resources/assets/images/favicon.svg deleted file mode 100644 index c353305c..00000000 --- a/theme_override/resources/assets/images/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file